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