import resolveResource, { filecheck } from './lib/resolve-resource.js'
import crypto from 'crypto'
import path from 'path'
/**
* Plugin factory
*
* @param {string} pkgName - NPM package name
* @returns {class}
*/
async function factory (pkgName) {
const me = this
/**
* BajoTemplate class
*
* @class
*/
class BajoTemplate extends this.app.pluginClass.base {
static alias = 'tpl'
constructor () {
super(pkgName, me.app)
this.config = {
layout: {
fallback: true
},
loopDetectorDur: '1m',
cache: {
maxAgeDur: '1s'
}
}
this.loopDetector = {}
}
buildCompileImports = (lang) => {
const _ = this.app.lib._
return {
_,
_t: (text, ...args) => {
const params = [...args, { lang }]
return this.t(text, ...params)
},
_format: (val, type, opts = {}) => {
opts.lang = opts.lang ?? 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>')
}
}
}
_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)
}
_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 = 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)
}
_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(opts.lang)
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
}
_renderString = async (content, locals = {}, opts = {}) => {
const { merge, without, isString, omit, kebabCase } = this.app.lib._
if (opts.ext === '.md' && this.app.bajoMarkdown) {
content = await this.compile(content, locals, { lang: opts.lang, ttl: -1 }) // 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 appTitle = this.t(locals.page.appTitle, { lang: opts.lang })
const fullTitle = locals.page.title ? `${locals.page.title} - ${appTitle}` : appTitle
locals.page.fullTitle = locals.fullTitle ?? fullTitle
}
content = await this.compile(content, locals, { lang: opts.lang, ttl: this.config.cache.maxAgeDur })
return await this._handleInclude(content, locals, opts)
}
getResource = (name) => {
const subNses = ['layout', 'template', 'partial']
const { ns, path, subNs, subSubNs, qs } = this.app.bajo.breakNsPath(name)
const plugin = this.app.bajo.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 }
}
parseFrontMatter = async (input = '', lang) => {
const { isEmpty, isPlainObject, isArray, filter, map } = this.app.lib._
const { parseObject } = this.app.bajo
const handlers = map(filter(this.app.configHandlers, h => !['.js'].includes(h.ext)), h => h.readHandler)
let success
for (const handler of handlers) {
if (success) break
try {
const result = await handler(input, true)
if (isPlainObject(result) || isArray(result)) success = result
} catch (err) {
}
}
if (isEmpty(success)) return {}
return parseObject(success, { parseValue: true, lang }) ?? {}
}
compile = async (content, locals, { lang, ttl = 0 } = {}) => {
locals.attr = locals.attr ?? {}
const _ = this.app.lib._
const { template } = _
const cache = this.app.bajoCache
let canCache = this.config.cache !== false && cache && this.app.bajo.config.env !== 'dev'
if (ttl === -1) canCache = false
const opts = {
imports: this.buildCompileImports(lang)
}
let item
if (canCache) {
const key = 'fn:' + crypto.createHash('md5').update(content).digest('hex')
const value = template(content, opts)
item = await cache.sync({ key, value, ttl })
} else {
item = template(content, opts)
}
return item(locals)
}
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 = async (tpl, locals = {}, opts = {}) => {
const { runHook, breakNsPath } = this.app.bajo
const { upperFirst } = this.app.lib._
const cache = 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 && cache && this.app.bajo.config.env !== 'dev'
if (canCache) {
const item = await cache.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 cache.set({ key, value: text, ttl: opts.cacheMaxAge ?? this.config.cache.maxAgeDur })
return text
}
resolveLayout = (item = '', opts = {}) => {
const { find } = this.app.lib._
const fallbackHandler = ({ file, exts, ns, subSubNs, type, theme }) => {
const dir = ''
const base = 'default'
if (!this.config.layout.fallback) return false
// check main: theme specific
if (theme && !file) {
const check = `${this.app.main.dir.pkg}/extend/${this.ns}/${type}/_${theme}`
file = filecheck.call(this, { dir, base, exts, check })
}
// check main: common
if (!file) {
const check = `${this.app.main.dir.pkg}/extend/${this.ns}/${type}`
file = filecheck.call(this, { dir, base, exts, check })
}
if (theme && !file) {
const otheme = find(this.app.waibuMpa.themes, { name: theme })
const check = `${otheme.plugin.dir.pkg}/extend/${this.ns}/extend/${this.ns}/${type}`
file = filecheck.call(this, { dir, base, exts, check })
}
// check fallback: common
if (!file) {
const check = `${this.app[ns].dir.pkg}/extend/${this.ns}/${subSubNs ? (subSubNs + '/') : ''}${type}`
file = filecheck.call(this, { dir, base, exts, check })
}
// check general fallback
if (!file) {
const check = `${this.dir.pkg}/extend/${this.ns}/${subSubNs ? (subSubNs + '/') : ''}${type}`
file = filecheck.call(this, { dir, base, exts, check })
}
return file
}
return resolveResource.call(this, 'layout', item, opts, fallbackHandler)
}
resolvePartial = (item = '', opts = {}) => {
return resolveResource.call(this, 'partial', item, opts)
}
resolveTemplate = (item = '', opts = {}) => {
return resolveResource.call(this, 'template', item, opts)
}
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 }
}
// based on: https://medium.com/@paulohfev/problem-solving-how-to-create-an-excerpt-fdb048687928
getExcerpt = (content, maxWords = 50, trailChars = '...') => {
const listOfWords = content.trim().split(' ')
const truncatedContent = listOfWords.slice(0, maxWords).join(' ')
const excerpt = truncatedContent + trailChars
const output = listOfWords.length > maxWords ? excerpt : content
return output
}
}
return BajoTemplate
}
export default factory