You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

713 lines
20 KiB

3 years ago
'use strict'
const fp = require('fastify-plugin')
const readFile = require('fs').readFile
const accessSync = require('fs').accessSync
const existsSync = require('fs').existsSync
const mkdirSync = require('fs').mkdirSync
const readdirSync = require('fs').readdirSync
const resolve = require('path').resolve
const join = require('path').join
const { basename, dirname, extname } = require('path')
const HLRU = require('hashlru')
const supportedEngines = ['ejs', 'nunjucks', 'pug', 'handlebars', 'mustache', 'art-template', 'twig', 'liquid', 'dot', 'eta']
function fastifyView (fastify, opts, next) {
if (!opts.engine) {
next(new Error('Missing engine'))
return
}
const type = Object.keys(opts.engine)[0]
if (supportedEngines.indexOf(type) === -1) {
next(new Error(`'${type}' not yet supported, PR? :)`))
return
}
const charset = opts.charset || 'utf-8'
const propertyName = opts.propertyName || 'view'
const engine = opts.engine[type]
const globalOptions = opts.options || {}
const templatesDir = resolveTemplateDir(opts)
const lru = HLRU(opts.maxCache || 100)
const includeViewExtension = opts.includeViewExtension || false
const viewExt = opts.viewExt || ''
const prod = typeof opts.production === 'boolean' ? opts.production : process.env.NODE_ENV === 'production'
const defaultCtx = opts.defaultContext || {}
const globalLayoutFileName = opts.layout
function templatesDirIsValid (_templatesDir) {
if (Array.isArray(_templatesDir) && type !== 'nunjucks') {
throw new Error('Only Nunjucks supports the "templates" option as an array')
}
}
function layoutIsValid (_layoutFileName) {
if (type !== 'dot' && type !== 'handlebars' && type !== 'ejs' && type !== 'eta') {
throw new Error('Only Dot, Handlebars, EJS, and Eta support the "layout" option')
}
if (!hasAccessToLayoutFile(_layoutFileName, getDefaultExtension(type))) {
throw new Error(`unable to access template "${_layoutFileName}"`)
}
}
try {
templatesDirIsValid(templatesDir)
if (globalLayoutFileName) {
layoutIsValid(globalLayoutFileName)
}
} catch (error) {
next(error)
return
}
const dotRender = type === 'dot' ? viewDot.call(fastify, preProcessDot.call(fastify, templatesDir, globalOptions)) : null
const nunjucksEnv = type === 'nunjucks' ? engine.configure(templatesDir, globalOptions) : null
const renders = {
ejs: withLayout(viewEjs, globalLayoutFileName),
handlebars: withLayout(viewHandlebars, globalLayoutFileName),
mustache: viewMustache,
nunjucks: viewNunjucks,
'art-template': viewArtTemplate,
twig: viewTwig,
liquid: viewLiquid,
dot: withLayout(dotRender, globalLayoutFileName),
eta: withLayout(viewEta, globalLayoutFileName),
_default: view
}
const renderer = renders[type] ? renders[type] : renders._default
function viewDecorator () {
const args = Array.from(arguments)
let done
if (typeof args[args.length - 1] === 'function') {
done = args.pop()
}
const promise = new Promise((resolve, reject) => {
renderer.apply({
getHeader: () => { },
header: () => { },
send: result => {
if (result instanceof Error) {
reject(result)
return
}
resolve(result)
}
}, args)
})
if (done && typeof done === 'function') {
promise.then(done.bind(null, null), done)
return
}
return promise
}
viewDecorator.clearCache = function () {
lru.clear()
}
fastify.decorate(propertyName, viewDecorator)
fastify.decorateReply(propertyName, function () {
renderer.apply(this, arguments)
return this
})
function getPage (page, extension) {
const pageLRU = `getPage-${page}-${extension}`
let result = lru.get(pageLRU)
if (typeof result === 'string') {
return result
}
const filename = basename(page, extname(page))
result = join(dirname(page), filename + getExtension(page, extension))
lru.set(pageLRU, result)
return result
}
function getDefaultExtension (type) {
const mappedExtensions = {
'art-template': 'art',
handlebars: 'hbs',
nunjucks: 'njk'
}
return viewExt || (mappedExtensions[type] || type)
}
function getExtension (page, extension) {
let filextension = extname(page)
if (!filextension) {
filextension = '.' + getDefaultExtension(type)
}
return viewExt ? `.${viewExt}` : (includeViewExtension ? `.${extension}` : filextension)
}
function isPathExcludedMinification (currentPath, pathsToExclude) {
return (pathsToExclude && Array.isArray(pathsToExclude)) ? pathsToExclude.includes(currentPath) : false
}
function useHtmlMinification (globalOpts, requestedPath) {
return globalOptions.useHtmlMinifier &&
(typeof globalOptions.useHtmlMinifier.minify === 'function') &&
!isPathExcludedMinification(requestedPath, globalOptions.pathsToExcludeHtmlMinifier)
}
function getRequestedPath (fastify) {
return (fastify && fastify.request) ? fastify.request.context.config.url : null
}
// Gets template as string (or precompiled for Handlebars)
// from LRU cache or filesystem.
const getTemplate = function (file, callback, requestedPath) {
const data = lru.get(file)
if (data && prod) {
callback(null, data)
} else {
readFile(join(templatesDir, file), 'utf-8', (err, data) => {
if (err) {
callback(err, null)
return
}
if (useHtmlMinification(globalOptions, requestedPath)) {
data = globalOptions.useHtmlMinifier.minify(data, globalOptions.htmlMinifierOptions || {})
}
if (type === 'handlebars') {
data = engine.compile(data)
}
lru.set(file, data)
callback(null, data)
})
}
}
// Gets partials as collection of strings from LRU cache or filesystem.
const getPartials = function (page, { partials, requestedPath }, callback) {
const cacheKey = getPartialsCacheKey(page, partials, requestedPath)
const partialsObj = lru.get(cacheKey)
if (partialsObj && prod) {
callback(null, partialsObj)
} else {
let filesToLoad = Object.keys(partials).length
if (filesToLoad === 0) {
callback(null, {})
return
}
let error = null
const partialsHtml = {}
Object.keys(partials).forEach((key, index) => {
readFile(join(templatesDir, partials[key]), 'utf-8', (err, data) => {
if (err) {
error = err
}
if (useHtmlMinification(globalOptions, requestedPath)) {
data = globalOptions.useHtmlMinifier.minify(data, globalOptions.htmlMinifierOptions || {})
}
partialsHtml[key] = data
if (--filesToLoad === 0) {
lru.set(cacheKey, partialsHtml)
callback(error, partialsHtml)
}
})
})
}
}
function getPartialsCacheKey (page, partials, requestedPath) {
let cacheKey = page
for (const key of Object.keys(partials)) {
cacheKey += `|${key}:${partials[key]}`
}
cacheKey += `|${requestedPath}-Partials`
return cacheKey
}
function readCallback (that, page, data) {
return function _readCallback (err, html) {
const requestedPath = getRequestedPath(that)
if (err) {
that.send(err)
return
}
let compiledPage
try {
if ((type === 'ejs') && viewExt && !globalOptions.includer) {
globalOptions.includer = (originalPath, parsedPath) => {
return {
filename: parsedPath || join(templatesDir, originalPath + '.' + viewExt)
}
}
}
globalOptions.filename = join(templatesDir, page)
compiledPage = engine.compile(html, globalOptions)
} catch (error) {
that.send(error)
return
}
lru.set(page, compiledPage)
if (!that.getHeader('content-type')) {
that.header('Content-Type', 'text/html; charset=' + charset)
}
let cachedPage
try {
cachedPage = lru.get(page)(data)
} catch (error) {
cachedPage = error
}
if (useHtmlMinification(globalOptions, requestedPath)) {
cachedPage = globalOptions.useHtmlMinifier.minify(cachedPage, globalOptions.htmlMinifierOptions || {})
}
that.send(cachedPage)
}
}
function preProcessDot (templatesDir, options) {
// Process all templates to in memory functions
// https://github.com/olado/doT#security-considerations
const destinationDir = options.destination || join(__dirname, 'out')
if (!existsSync(destinationDir)) {
mkdirSync(destinationDir)
}
const renderer = engine.process(Object.assign(
{},
options,
{
path: templatesDir,
destination: destinationDir
}
))
// .jst files are compiled to .js files so we need to require them
for (const file of readdirSync(destinationDir, { withFileTypes: false })) {
renderer[basename(file, '.js')] = require(resolve(join(destinationDir, file)))
}
if (Object.keys(renderer).length === 0) {
this.log.warn(`WARN: no template found in ${templatesDir}`)
}
return renderer
}
function view (page, data) {
if (!page) {
this.send(new Error('Missing page'))
return
}
data = Object.assign({}, defaultCtx, this.locals, data)
// append view extension
page = getPage(page, type)
const toHtml = lru.get(page)
if (toHtml && prod) {
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(toHtml(data))
return
}
readFile(join(templatesDir, page), 'utf8', readCallback(this, page, data))
}
function viewEjs (page, data, opts) {
if (opts && opts.layout) {
try {
layoutIsValid(opts.layout)
const that = this
return withLayout(viewEjs, opts.layout).call(that, page, data)
} catch (error) {
this.send(error)
return
}
}
if (!page) {
this.send(new Error('Missing page'))
return
}
data = Object.assign({}, defaultCtx, this.locals, data)
// append view extension
page = getPage(page, type)
const requestedPath = getRequestedPath(this)
getTemplate(page, (err, template) => {
if (err) {
this.send(err)
return
}
const toHtml = lru.get(page)
if (toHtml && prod && (typeof (toHtml) === 'function')) {
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(toHtml(data))
return
}
readFile(join(templatesDir, page), 'utf8', readCallback(this, page, data))
}, requestedPath)
}
function viewArtTemplate (page, data) {
if (!page) {
this.send(new Error('Missing page'))
return
}
data = Object.assign({}, defaultCtx, this.locals, data)
// Append view extension.
page = getPage(page, 'art')
const defaultSetting = {
debug: process.env.NODE_ENV !== 'production',
root: templatesDir
}
// merge engine options
const confs = Object.assign({}, defaultSetting, globalOptions)
function render (filename, data) {
confs.filename = join(templatesDir, filename)
const render = engine.compile(confs)
return render(data)
}
try {
const html = render(page, data)
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(html)
} catch (error) {
this.send(error)
}
}
function viewNunjucks (page, data) {
if (!page) {
this.send(new Error('Missing page'))
return
}
if (typeof globalOptions.onConfigure === 'function') {
globalOptions.onConfigure(nunjucksEnv)
}
data = Object.assign({}, defaultCtx, this.locals, data)
// Append view extension.
page = getPage(page, 'njk')
nunjucksEnv.render(page, data, (err, html) => {
const requestedPath = getRequestedPath(this)
if (err) return this.send(err)
if (useHtmlMinification(globalOptions, requestedPath)) {
html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {})
}
this.header('Content-Type', 'text/html; charset=' + charset)
this.send(html)
})
}
function viewHandlebars (page, data, opts) {
if (opts && opts.layout) {
try {
layoutIsValid(opts.layout)
const that = this
return withLayout(viewHandlebars, opts.layout).call(that, page, data)
} catch (error) {
this.send(error)
return
}
}
if (!page) {
this.send(new Error('Missing page'))
return
}
const options = Object.assign({}, globalOptions)
data = Object.assign({}, defaultCtx, this.locals, data)
// append view extension
page = getPage(page, 'hbs')
const requestedPath = getRequestedPath(this)
getTemplate(page, (err, template) => {
if (err) {
this.send(err)
return
}
if (prod) {
try {
const html = template(data)
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(html)
} catch (e) {
this.send(e)
}
} else {
getPartials(type, { partials: options.partials || {}, requestedPath: requestedPath }, (err, partialsObject) => {
if (err) {
this.send(err)
return
}
try {
Object.keys(partialsObject).forEach((name) => {
engine.registerPartial(name, engine.compile(partialsObject[name]))
})
const html = template(data)
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(html)
} catch (e) {
this.send(e)
}
})
}
}, requestedPath)
}
function viewMustache (page, data, opts) {
if (!page) {
this.send(new Error('Missing page'))
return
}
const options = Object.assign({}, opts)
data = Object.assign({}, defaultCtx, this.locals, data)
// append view extension
page = getPage(page, 'mustache')
const requestedPath = getRequestedPath(this)
getTemplate(page, (err, templateString) => {
if (err) {
this.send(err)
return
}
getPartials(page, { partials: options.partials || {}, requestedPath: requestedPath }, (err, partialsObject) => {
if (err) {
this.send(err)
return
}
const html = engine.render(templateString, data, partialsObject)
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(html)
})
}, requestedPath)
}
function viewTwig (page, data, opts) {
if (!page) {
this.send(new Error('Missing page'))
return
}
data = Object.assign({}, defaultCtx, globalOptions, this.locals, data)
// Append view extension.
page = getPage(page, 'twig')
engine.renderFile(join(templatesDir, page), data, (err, html) => {
const requestedPath = getRequestedPath(this)
if (err) {
return this.send(err)
}
if (useHtmlMinification(globalOptions, requestedPath)) {
html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {})
}
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(html)
})
}
function viewLiquid (page, data, opts) {
if (!page) {
this.send(new Error('Missing page'))
return
}
data = Object.assign({}, defaultCtx, this.locals, data)
// Append view extension.
page = getPage(page, 'liquid')
engine.renderFile(join(templatesDir, page), data, opts)
.then((html) => {
const requestedPath = getRequestedPath(this)
if (useHtmlMinification(globalOptions, requestedPath)) {
html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {})
}
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(html)
})
.catch((err) => {
this.send(err)
})
}
function viewDot (renderModule) {
return function _viewDot (page, data, opts) {
if (opts && opts.layout) {
try {
layoutIsValid(opts.layout)
const that = this
return withLayout(dotRender, opts.layout).call(that, page, data)
} catch (error) {
this.send(error)
return
}
}
if (!page) {
this.send(new Error('Missing page'))
return
}
data = Object.assign({}, defaultCtx, this.locals, data)
let html = renderModule[page](data)
const requestedPath = getRequestedPath(this)
if (useHtmlMinification(globalOptions, requestedPath)) {
html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {})
}
if (!this.getHeader('content-type')) {
this.header('Content-Type', 'text/html; charset=' + charset)
}
this.send(html)
}
}
function viewEta (page, data, opts) {
if (opts && opts.layout) {
try {
layoutIsValid(opts.layout)
const that = this
return withLayout(viewEta, opts.layout).call(that, page, data)
} catch (error) {
this.send(error)
return
}
}
if (!page) {
this.send(new Error('Missing page'))
return
}
lru.define = lru.set
engine.configure({
templates: globalOptions.templates ? globalOptions.templates : lru
})
const config = Object.assign({
cache: prod,
views: templatesDir
}, globalOptions)
data = Object.assign({}, defaultCtx, this.locals, data)
// Append view extension (Eta will append '.eta' by default,
// but this also allows custom extensions)
page = getPage(page, 'eta')
engine.renderFile(page, data, config, (err, html) => {
if (err) return this.send(err)
if (
config.useHtmlMinifier &&
typeof config.useHtmlMinifier.minify === 'function' &&
!isPathExcludedMinification(getRequestedPath(this), config.pathsToExcludeHtmlMinifier)
) {
html = config.useHtmlMinifier.minify(
html,
config.htmlMinifierOptions || {}
)
}
this.header('Content-Type', 'text/html; charset=' + charset)
this.send(html)
})
}
if (prod && type === 'handlebars' && globalOptions.partials) {
getPartials(type, { partials: globalOptions.partials || {}, requestedPath: getRequestedPath(this) }, (err, partialsObject) => {
if (err) {
next(err)
return
}
Object.keys(partialsObject).forEach((name) => {
engine.registerPartial(name, engine.compile(partialsObject[name]))
})
next()
})
} else {
next()
}
function withLayout (render, layout) {
if (layout) {
return function (page, data, opts) {
if (opts && opts.layout) throw new Error('A layout can either be set globally or on render, not both.')
const that = this
data = Object.assign({}, defaultCtx, this.locals, data)
render.call({
getHeader: () => { },
header: () => { },
send: (result) => {
if (result instanceof Error) {
throw result
}
data = Object.assign((data || {}), { body: result })
render.call(that, layout, data, opts)
}
}, page, data, opts)
}
}
return render
}
function resolveTemplateDir (_opts) {
if (_opts.root) {
return _opts.root
}
return Array.isArray(_opts.templates)
? _opts.templates.map((dir) => resolve(dir))
: resolve(_opts.templates || './')
}
function hasAccessToLayoutFile (fileName, ext) {
try {
accessSync(join(templatesDir, getPage(fileName, ext)))
return true
} catch (e) {
return false
}
}
}
module.exports = fp(fastifyView, {
fastify: '3.x',
name: 'point-of-view'
})