Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
fastify
GitHub Repository: fastify/point-of-view
Path: blob/main/index.js
162 views
1
'use strict'
2
const { readFile } = require('node:fs/promises')
3
const fp = require('fastify-plugin')
4
const { accessSync, existsSync, mkdirSync, readdirSync } = require('node:fs')
5
const { basename, dirname, extname, join, resolve } = require('node:path')
6
const { LruMap } = require('toad-cache')
7
const supportedEngines = ['ejs', 'nunjucks', 'pug', 'handlebars', 'mustache', 'twig', 'liquid', 'dot', 'eta', 'edge', 'squirrelly']
8
9
const viewCache = Symbol('@fastify/view/cache')
10
11
const fastifyViewCache = fp(
12
async function cachePlugin (fastify, opts) {
13
const lru = new LruMap(opts.maxCache || 100)
14
fastify.decorate(viewCache, lru)
15
},
16
{
17
fastify: '5.x',
18
name: '@fastify/view/cache'
19
}
20
)
21
22
async function fastifyView (fastify, opts) {
23
if (fastify[viewCache] === undefined) {
24
await fastify.register(fastifyViewCache, opts)
25
}
26
if (!opts.engine) {
27
throw new Error('Missing engine')
28
}
29
const type = Object.keys(opts.engine)[0]
30
if (supportedEngines.indexOf(type) === -1) {
31
throw new Error(`'${type}' not yet supported, PR? :)`)
32
}
33
const charset = opts.charset || 'utf-8'
34
const propertyName = opts.propertyName || 'view'
35
const asyncPropertyName = opts.asyncPropertyName || `${propertyName}Async`
36
const engine = await opts.engine[type]
37
const globalOptions = opts.options || {}
38
const templatesDir = resolveTemplateDir(opts)
39
const includeViewExtension = opts.includeViewExtension || false
40
const viewExt = opts.viewExt || ''
41
const prod = typeof opts.production === 'boolean' ? opts.production : process.env.NODE_ENV === 'production'
42
const defaultCtx = opts.defaultContext
43
const globalLayoutFileName = opts.layout
44
45
/**
46
* @type {Map<string, Promise>}
47
*/
48
const readFileMap = new Map()
49
50
function readFileSemaphore (filePath) {
51
if (readFileMap.has(filePath) === false) {
52
const promise = readFile(filePath, 'utf-8')
53
readFileMap.set(filePath, promise)
54
return promise.finally(() => readFileMap.delete(filePath))
55
}
56
return readFileMap.get(filePath)
57
}
58
59
function templatesDirIsValid (_templatesDir) {
60
if (Array.isArray(_templatesDir) && type !== 'nunjucks') {
61
throw new Error('Only Nunjucks supports the "templates" option as an array')
62
}
63
}
64
65
function layoutIsValid (_layoutFileName) {
66
if (type !== 'dot' && type !== 'handlebars' && type !== 'ejs' && type !== 'eta') {
67
throw new Error('Only Dot, Handlebars, EJS and Eta support the "layout" option')
68
}
69
70
if (!hasAccessToLayoutFile(_layoutFileName, getDefaultExtension(type))) {
71
throw new Error(`unable to access template "${_layoutFileName}"`)
72
}
73
}
74
75
function setupNunjucksEnv (_engine) {
76
if (type === 'nunjucks') {
77
const env = _engine.configure(templatesDir, globalOptions)
78
if (typeof globalOptions.onConfigure === 'function') {
79
globalOptions.onConfigure(env)
80
}
81
return env
82
}
83
return null
84
}
85
86
templatesDirIsValid(templatesDir)
87
88
if (globalLayoutFileName) {
89
layoutIsValid(globalLayoutFileName)
90
}
91
92
const dotRender = type === 'dot' ? viewDot.call(fastify, preProcessDot.call(fastify, templatesDir, globalOptions)) : null
93
const nunjucksEnv = setupNunjucksEnv(engine)
94
95
const renders = {
96
ejs: withLayout(viewEjs, globalLayoutFileName),
97
handlebars: withLayout(viewHandlebars, globalLayoutFileName),
98
mustache: viewMustache,
99
nunjucks: viewNunjucks,
100
twig: viewTwig,
101
liquid: viewLiquid,
102
dot: withLayout(dotRender, globalLayoutFileName),
103
eta: withLayout(viewEta, globalLayoutFileName),
104
edge: viewEdge,
105
squirrelly: viewSquirrelly,
106
_default: view
107
}
108
109
const renderer = renders[type] ? renders[type] : renders._default
110
111
async function asyncRender (page) {
112
if (!page) {
113
throw new Error('Missing page')
114
}
115
116
let result = await renderer.apply(this, arguments)
117
118
if (minify && !isPathExcludedMinification(this)) {
119
result = await minify(result, globalOptions.htmlMinifierOptions)
120
}
121
122
if (this.getHeader && !this.getHeader('Content-Type')) {
123
this.header('Content-Type', 'text/html; charset=' + charset)
124
}
125
126
return result
127
}
128
129
function viewDecorator () {
130
const args = Array.from(arguments)
131
132
let done
133
if (typeof args[args.length - 1] === 'function') {
134
done = args.pop()
135
}
136
137
const promise = asyncRender.apply({}, args)
138
139
if (typeof done === 'function') {
140
promise.then(done.bind(null, null), done)
141
return
142
}
143
144
return promise
145
}
146
147
viewDecorator.clearCache = function () {
148
fastify[viewCache].clear()
149
}
150
151
fastify.decorate(propertyName, viewDecorator)
152
153
fastify.decorateReply(propertyName, async function (page, data, opts) {
154
try {
155
const html = await asyncRender.call(this, page, data, opts)
156
this.send(html)
157
} catch (err) {
158
this.send(err)
159
}
160
161
return this
162
})
163
164
fastify.decorateReply(asyncPropertyName, asyncRender)
165
166
if (!fastify.hasReplyDecorator('locals')) {
167
fastify.decorateReply('locals', null)
168
169
fastify.addHook('onRequest', (_req, reply, done) => {
170
reply.locals = {}
171
done()
172
})
173
}
174
175
function getPage (page, extension) {
176
const pageLRU = `getPage-${page}-${extension}`
177
let result = fastify[viewCache].get(pageLRU)
178
179
if (typeof result === 'string') {
180
return result
181
}
182
183
const filename = basename(page, extname(page))
184
result = join(dirname(page), filename + getExtension(page, extension))
185
186
fastify[viewCache].set(pageLRU, result)
187
188
return result
189
}
190
191
function getCacheKey (key) {
192
return [propertyName, key].join('|')
193
}
194
195
function getDefaultExtension (type) {
196
const mappedExtensions = {
197
handlebars: 'hbs',
198
nunjucks: 'njk'
199
}
200
201
return viewExt || (mappedExtensions[type] || type)
202
}
203
204
function getExtension (page, extension) {
205
let filextension = extname(page)
206
if (!filextension) {
207
filextension = '.' + getDefaultExtension(type)
208
}
209
210
return viewExt ? `.${viewExt}` : (includeViewExtension ? `.${extension}` : filextension)
211
}
212
213
const minify = typeof globalOptions.useHtmlMinifier?.minify === 'function'
214
? globalOptions.useHtmlMinifier.minify
215
: null
216
217
const minifyExcludedPaths = Array.isArray(globalOptions.pathsToExcludeHtmlMinifier)
218
? new Set(globalOptions.pathsToExcludeHtmlMinifier)
219
: null
220
221
function getRequestedPath (fastify) {
222
return fastify?.request?.routeOptions.url ?? null
223
}
224
function isPathExcludedMinification (that) {
225
return minifyExcludedPaths?.has(getRequestedPath(that))
226
}
227
function onTemplatesLoaded (file, data) {
228
if (type === 'handlebars') {
229
data = engine.compile(data, globalOptions.compileOptions)
230
}
231
fastify[viewCache].set(getCacheKey(file), data)
232
return data
233
}
234
235
// Gets template as string (or precompiled for Handlebars)
236
// from LRU cache or filesystem.
237
const getTemplate = async function (file) {
238
if (typeof file === 'function') {
239
return file
240
}
241
let isRaw = false
242
if (typeof file === 'object' && file.raw) {
243
isRaw = true
244
file = file.raw
245
}
246
const data = fastify[viewCache].get(getCacheKey(file))
247
if (data && prod) {
248
return data
249
}
250
if (isRaw) {
251
return onTemplatesLoaded(file, file)
252
}
253
const fileData = await readFileSemaphore(join(templatesDir, file))
254
return onTemplatesLoaded(file, fileData)
255
}
256
257
// Gets partials as collection of strings from LRU cache or filesystem.
258
const getPartials = async function (page, { partials, requestedPath }) {
259
const cacheKey = getPartialsCacheKey(page, partials, requestedPath)
260
const partialsObj = fastify[viewCache].get(cacheKey)
261
if (partialsObj && prod) {
262
return partialsObj
263
} else {
264
const partialKeys = Object.keys(partials)
265
if (partialKeys.length === 0) {
266
return {}
267
}
268
const partialsHtml = {}
269
await Promise.all(partialKeys.map(async (key) => {
270
partialsHtml[key] = await readFileSemaphore(join(templatesDir, partials[key]))
271
}))
272
fastify[viewCache].set(cacheKey, partialsHtml)
273
return partialsHtml
274
}
275
}
276
277
function getPartialsCacheKey (page, partials, requestedPath) {
278
let cacheKey = getCacheKey(page)
279
280
for (const key of Object.keys(partials)) {
281
cacheKey += `|${key}:${partials[key]}`
282
}
283
284
cacheKey += `|${requestedPath}-Partials`
285
286
return cacheKey
287
}
288
289
function readCallbackParser (page, html, localOptions) {
290
if ((type === 'ejs') && viewExt && !globalOptions.includer) {
291
globalOptions.includer = (originalPath, parsedPath) => ({
292
filename: parsedPath || join(templatesDir, originalPath + '.' + viewExt)
293
})
294
}
295
if (localOptions) {
296
for (const key in globalOptions) {
297
if (!Object.hasOwn(localOptions, key)) localOptions[key] = globalOptions[key]
298
}
299
} else localOptions = globalOptions
300
301
const compiledPage = engine.compile(html, localOptions)
302
303
fastify[viewCache].set(getCacheKey(page), compiledPage)
304
return compiledPage
305
}
306
307
function readCallback (page, _data, localOptions, html) {
308
globalOptions.filename = join(templatesDir, page)
309
return readCallbackParser(page, html, localOptions)
310
}
311
312
function preProcessDot (templatesDir, options) {
313
// Process all templates to in memory functions
314
// https://github.com/olado/doT#security-considerations
315
const destinationDir = options.destination || join(__dirname, 'out')
316
if (!existsSync(destinationDir)) {
317
mkdirSync(destinationDir)
318
}
319
320
const renderer = engine.process(Object.assign(
321
{},
322
options,
323
{
324
path: templatesDir,
325
destination: destinationDir
326
}
327
))
328
329
// .jst files are compiled to .js files so we need to require them
330
for (const file of readdirSync(destinationDir, { withFileTypes: false })) {
331
renderer[basename(file, '.js')] = require(resolve(join(destinationDir, file)))
332
}
333
if (Object.keys(renderer).length === 0) {
334
this.log.warn(`WARN: no template found in ${templatesDir}`)
335
}
336
337
return renderer
338
}
339
340
async function view (page, data, opts) {
341
data = Object.assign({}, defaultCtx, this.locals, data)
342
if (typeof page === 'function') {
343
return page(data)
344
}
345
let isRaw = false
346
if (typeof page === 'object' && page.raw) {
347
isRaw = true
348
page = page.raw.toString()
349
} else {
350
// append view extension
351
page = getPage(page, type)
352
}
353
const toHtml = fastify[viewCache].get(getCacheKey(page))
354
355
if (toHtml && prod) {
356
return toHtml(data)
357
} else if (isRaw) {
358
const compiledPage = readCallbackParser(page, page, opts)
359
return compiledPage(data)
360
}
361
362
const file = await readFileSemaphore(join(templatesDir, page))
363
const render = readCallback(page, data, opts, file)
364
return render(data)
365
}
366
367
async function viewEjs (page, data, opts) {
368
if (opts?.layout) {
369
layoutIsValid(opts.layout)
370
return withLayout(viewEjs, opts.layout).call(this, page, data)
371
}
372
data = Object.assign({}, defaultCtx, this.locals, data)
373
if (typeof page === 'function') {
374
return page(data)
375
}
376
let isRaw = false
377
if (typeof page === 'object' && page.raw) {
378
isRaw = true
379
page = page.raw.toString()
380
} else {
381
// append view extension
382
page = getPage(page, type)
383
}
384
const toHtml = fastify[viewCache].get(getCacheKey(page))
385
386
if (toHtml && prod) {
387
return toHtml(data)
388
} else if (isRaw) {
389
const compiledPage = readCallbackParser(page, page, opts)
390
return compiledPage(data)
391
}
392
393
const file = await readFileSemaphore(join(templatesDir, page))
394
const render = readCallback(page, data, opts, file)
395
return render(data)
396
}
397
398
async function viewNunjucks (page, data) {
399
data = Object.assign({}, defaultCtx, this.locals, data)
400
let render
401
if (typeof page === 'string') {
402
// Append view extension.
403
page = getPage(page, 'njk')
404
render = nunjucksEnv.render.bind(nunjucksEnv, page)
405
} else if (typeof page === 'object' && typeof page.render === 'function') {
406
render = page.render.bind(page)
407
} else if (typeof page === 'object' && page.raw) {
408
render = nunjucksEnv.renderString.bind(nunjucksEnv, page.raw.toString())
409
} else {
410
throw new Error('Unknown template type')
411
}
412
return new Promise((resolve, reject) => {
413
render(data, (err, html) => {
414
if (err) {
415
reject(err)
416
return
417
}
418
419
resolve(html)
420
})
421
})
422
}
423
424
async function viewHandlebars (page, data, opts) {
425
if (opts?.layout) {
426
layoutIsValid(opts.layout)
427
return withLayout(viewHandlebars, opts.layout).call(this, page, data)
428
}
429
430
let options
431
432
if (globalOptions.useDataVariables) {
433
options = {
434
data: defaultCtx ? Object.assign({}, defaultCtx, this.locals) : this.locals
435
}
436
} else {
437
data = Object.assign({}, defaultCtx, this.locals, data)
438
}
439
440
if (typeof page === 'string') {
441
// append view extension
442
page = getPage(page, 'hbs')
443
}
444
const requestedPath = getRequestedPath(this)
445
const template = await getTemplate(page)
446
447
if (prod) {
448
return template(data, options)
449
} else {
450
const partialsObject = await getPartials(type, { partials: globalOptions.partials || {}, requestedPath })
451
452
Object.keys(partialsObject).forEach((name) => {
453
engine.registerPartial(name, engine.compile(partialsObject[name], globalOptions.compileOptions))
454
})
455
456
return template(data, options)
457
}
458
}
459
460
async function viewMustache (page, data, opts) {
461
const options = Object.assign({}, opts)
462
data = Object.assign({}, defaultCtx, this.locals, data)
463
if (typeof page === 'string') {
464
// append view extension
465
page = getPage(page, 'mustache')
466
}
467
const partials = Object.assign({}, globalOptions.partials || {}, options.partials || {})
468
const requestedPath = getRequestedPath(this)
469
const templateString = await getTemplate(page)
470
const partialsObject = await getPartials(page, { partials, requestedPath })
471
472
let html
473
if (typeof templateString === 'function') {
474
html = templateString(data, partialsObject)
475
} else {
476
html = engine.render(templateString, data, partialsObject)
477
}
478
479
return html
480
}
481
482
async function viewTwig (page, data) {
483
data = Object.assign({}, defaultCtx, globalOptions, this.locals, data)
484
let render
485
if (typeof page === 'string') {
486
// Append view extension.
487
page = getPage(page, 'twig')
488
render = engine.renderFile.bind(engine, join(templatesDir, page))
489
} else if (typeof page === 'object' && typeof page.render === 'function') {
490
render = (data, cb) => cb(null, page.render(data))
491
} else if (typeof page === 'object' && page.raw) {
492
render = (data, cb) => cb(null, engine.twig({ data: page.raw.toString() }).render(data))
493
} else {
494
throw new Error('Unknown template type')
495
}
496
return new Promise((resolve, reject) => {
497
render(data, (err, html) => {
498
if (err) {
499
reject(err)
500
return
501
}
502
503
resolve(html)
504
})
505
})
506
}
507
508
async function viewLiquid (page, data, opts) {
509
data = Object.assign({}, defaultCtx, this.locals, data)
510
let render
511
if (typeof page === 'string') {
512
// Append view extension.
513
page = getPage(page, 'liquid')
514
render = engine.renderFile.bind(engine, join(templatesDir, page))
515
} else if (typeof page === 'function') {
516
render = page
517
} else if (typeof page === 'object' && page.raw) {
518
const templates = engine.parse(page.raw)
519
render = engine.render.bind(engine, templates)
520
}
521
522
return render(data, opts)
523
}
524
525
function viewDot (renderModule) {
526
return async function _viewDot (page, data, opts) {
527
if (opts?.layout) {
528
layoutIsValid(opts.layout)
529
return withLayout(dotRender, opts.layout).call(this, page, data)
530
}
531
data = Object.assign({}, defaultCtx, this.locals, data)
532
let render
533
if (typeof page === 'function') {
534
render = page
535
} else if (typeof page === 'object' && page.raw) {
536
render = engine.template(page.raw.toString(), { ...engine.templateSettings, ...globalOptions, ...page.settings }, page.imports)
537
} else {
538
render = renderModule[page]
539
}
540
return render(data)
541
}
542
}
543
544
async function viewEta (page, data, opts) {
545
if (opts?.layout) {
546
layoutIsValid(opts.layout)
547
return withLayout(viewEta, opts.layout).call(this, page, data)
548
}
549
550
if (globalOptions.templatesSync) {
551
engine.templatesSync = globalOptions.templatesSync
552
}
553
554
engine.configure({
555
views: templatesDir,
556
cache: prod || globalOptions.templatesSync
557
})
558
559
const config = Object.assign({
560
cache: prod,
561
views: templatesDir
562
}, globalOptions)
563
564
data = Object.assign({}, defaultCtx, this.locals, data)
565
566
if (typeof page === 'function') {
567
const ret = await page.call(engine, data, config)
568
return ret
569
}
570
571
let render, renderAsync
572
if (typeof page === 'object' && page.raw) {
573
page = page.raw.toString()
574
render = engine.renderString.bind(engine)
575
renderAsync = engine.renderStringAsync.bind(engine)
576
} else {
577
// Append view extension (Eta will append '.eta' by default,
578
// but this also allows custom extensions)
579
page = getPage(page, 'eta')
580
render = engine.render.bind(engine)
581
renderAsync = engine.renderAsync.bind(engine)
582
}
583
584
/* c8 ignore next */
585
if (opts?.async ?? globalOptions.async) {
586
return renderAsync(page, data, config)
587
} else {
588
return render(page, data, config)
589
}
590
}
591
592
if (prod && type === 'handlebars' && globalOptions.partials) {
593
const partialsObject = await getPartials(type, { partials: globalOptions.partials, requestedPath: getRequestedPath(this) })
594
Object.keys(partialsObject).forEach((name) => {
595
engine.registerPartial(name, engine.compile(partialsObject[name], globalOptions.compileOptions))
596
})
597
}
598
599
async function viewEdge (page, data, opts) {
600
data = Object.assign({}, defaultCtx, this.locals, data)
601
602
switch (typeof page) {
603
case 'string':
604
return engine.render(getPage(page, 'edge'), data)
605
case 'function':
606
return page(data)
607
case 'object':
608
return engine.renderRaw(page, data)
609
default:
610
throw new Error('Unknown page type')
611
}
612
}
613
614
async function viewSquirrelly (page, data, opts) {
615
data = Object.assign({}, defaultCtx, this.locals, data)
616
617
if (typeof page === 'function') {
618
return page(data)
619
}
620
621
let isRaw = false
622
if (typeof page === 'object' && page.raw) {
623
isRaw = true
624
page = page.raw.toString()
625
} else {
626
page = getPage(page, 'squirrelly')
627
}
628
629
const config = Object.assign({}, globalOptions)
630
631
if (isRaw) {
632
return engine.render(page, data, config)
633
}
634
635
const file = await readFileSemaphore(join(templatesDir, page))
636
return engine.render(file, data, config)
637
}
638
639
function withLayout (render, layout) {
640
if (layout) {
641
return async function (page, data, opts) {
642
if (opts?.layout) throw new Error('A layout can either be set globally or on render, not both.')
643
data = Object.assign({}, defaultCtx, this.locals, data)
644
const result = await render.call(this, page, data, opts)
645
data = Object.assign(data, { body: result })
646
return render.call(this, layout, data, opts)
647
}
648
}
649
return render
650
}
651
652
function resolveTemplateDir (_opts) {
653
if (_opts.root) {
654
return _opts.root
655
}
656
657
return Array.isArray(_opts.templates)
658
? _opts.templates.map((dir) => resolve(dir))
659
: resolve(_opts.templates || './')
660
}
661
662
function hasAccessToLayoutFile (fileName, ext) {
663
const layoutKey = getCacheKey(`layout-${fileName}-${ext}`)
664
let result = fastify[viewCache].get(layoutKey)
665
666
if (typeof result === 'boolean') {
667
return result
668
}
669
670
try {
671
accessSync(join(templatesDir, getPage(fileName, ext)))
672
result = true
673
} catch {
674
result = false
675
}
676
677
fastify[viewCache].set(layoutKey, result)
678
679
return result
680
}
681
}
682
683
module.exports = fp(fastifyView, {
684
fastify: '5.x',
685
name: '@fastify/view'
686
})
687
module.exports.default = fastifyView
688
module.exports.fastifyView = fastifyView
689
module.exports.fastifyViewCache = viewCache
690
691