diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d7dbf9a7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +quote_type = single + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000..7d03cee4 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "standard" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3c27edcd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ + Set the default behavior, in case people don't have core.autocrlf set +* text=auto + +# Require Unix line endings +* text eol=lf \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..f59ec20a --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +* \ No newline at end of file diff --git a/README.md b/README.md index 95564a63..390cc5f4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Templates rendering plugin support for Fastify. `point-of-view` decorates the reply interface with the `view` method for managing view engines, which can be used to render templates responses. Currently supports the following templates engines: + - [`ejs`](https://ejs.co/) - [`nunjucks`](https://mozilla.github.io/nunjucks/) - [`pug`](https://pugjs.org/api/getting-started.html) @@ -24,13 +25,15 @@ Currently supports the following templates engines: In `production` mode, `point-of-view` will heavily cache the templates file and functions, while in `development` will reload every time the template file and function. -*Note that at least Fastify `v2.0.0` is needed.* +_Note that at least Fastify `v2.0.0` is needed._ -*Note: [`ejs-mate`](https://github.com/JacksonTian/ejs-mate) support [has been dropped](https://github.com/fastify/point-of-view/pull/157).* +_Note: [`ejs-mate`](https://github.com/JacksonTian/ejs-mate) support [has been dropped](https://github.com/fastify/point-of-view/pull/157)._ #### Benchmarks + The benchmark were run with the files in the `benchmark` folder with the `ejs` engine. The data has been taken with: `autocannon -c 100 -d 5 -p 10 localhost:3000` + - Express: 8.8k req/sec - **Fastify**: 15.6k req/sec @@ -40,120 +43,234 @@ The data has been taken with: `autocannon -c 100 -d 5 -p 10 localhost:3000` npm install point-of-view --save ``` - -## Usage + + +## Quick start + +`fastify.register` is used to register point-of-view. By default, It will decorate the `reply` object with a `view` method that takes at least two arguments: + +- the template to be rendered +- the data that should be available to the template during rendering + +This example will render the template and provide a variable `text` to be used inside the template: + ```js -const fastify = require('fastify')() +const fastify = require("fastify")(); -fastify.register(require('point-of-view'), { +fastify.register(require("point-of-view"), { engine: { - ejs: require('ejs') - } -}) + ejs: require("ejs"), + }, +}); -fastify.get('/', (req, reply) => { - reply.view('/templates/index.ejs', { text: 'text' }) -}) +fastify.get("/", (req, reply) => { + reply.view("/templates/index.ejs", { text: "text" }); +}); -// With async handler be sure to return the result of reply.view -fastify.get('/', async (req, reply) => { - const t = await something() - return reply.view('/templates/index.ejs', { text: 'text' }) -}) +fastify.listen(3000, (err) => { + if (err) throw err; + console.log(`server listening on ${fastify.server.address().port}`); +}); +``` -fastify.listen(3000, err => { - if (err) throw err - console.log(`server listening on ${fastify.server.address().port}`) -}) +If your handler function is asynchronous, make sure to return the result - otherwise this will result in an `FST_ERR_PROMISE_NOT_FULFILLED` error: + +```js +// This is an async function +fastify.get("/", async (req, reply) => { + // We are awaiting a functioon result + const t = await something(); + + // Note the return statement + return reply.view("/templates/index.ejs", { text: "text" }); +}); ``` -Or render a template directly with the `fastify.view()` decorator: +## Configuration + +`fastify.register(, )` accepts an options object. + +### Options + +- `engine`: The template engine object - pass in the return value of `require('')`. This option is mandatory. +- `layout`: Point-of-view supports layouts for **EJS**, **Handlebars**, **Eta** and **doT**. This option lets you specify a global layout file to be used when rendering your templates. Settings like `root` or `viewExt` apply as for any other template file. Example: `./templates/layouts/main.hbs` +- `propertyName`: The property that should be used to decorate `reply` and `fastify` - E.g. `reply.view()` and `fastify.view()` where `"view"` is the property name. Default: `"view"`. +- `root`: The root path of your templates folder. The template name or path passed to the render function will be resolved relative to this path. Default: `"./"`. +- `includeViewExtension`: Setting this to `true` will automatically append the default extension for the used template engine **if ommited from the template name** . So instead of `template.hbs`, just `template` can be used. Default: `false`. +- `viewExt`: Let's you override the default extension for a given template engine. This has precedence over `includeViewExtension` and will lead to the same behavior, just with a custom extension. Default `""`. Example: `"handlebars"`. +- `defaultContext`: The template variables defined here will be available to all views. Variables provided on render have precendence and will **override** this if they have the same name. Default: `{}`. Example: `{ siteName: "MyAwesomeSite" }`. + +Example: + ```js -// With a promise -const html = await fastify.view('/templates/index.ejs', { text: 'text' }) +fastify.register(require("point-of-view"), { + engine: { + handlebars: require("handlebars"), + }, + root: path.join(__dirname, "views"), // Points to `./views` relative to the current file + layout: "./templates/template", // Sets the layout to use to `./views/templates/layout.handlebars` relative to the current file. + viewExt: "handlebars", // Sets the default extension to `.handlebars` + propertyName: "render", // The template can now be rendered via `reply.render()` and `fastify.render()` + defaultContext: { + dev: process.env.NODE_ENV === "development", // Inside your templates, `dev` will be `true` if the expression evaluates to true + }, + options: {}, // No options passed to handlebars +}); +``` -// or with a callback -fastify.view('/templates/index.ejs', { text: 'text' }, (err, html) => { - // ... -}) +## Rendering the template into a variable + +The `fastify` object is decorated the same way as `reply` and allows you to just render a view into a variable instead of sending the result back to the browser: + +```js +// Promise based, using async/await +const html = await fastify.view("/templates/index.ejs", { text: "text" }); + +// Callback based +fastify.view("/templates/index.ejs", { text: "text" }, (err, html) => { + // Handle error + // Do something with `html` +}); ``` -Like having 2 different declarations with different propertynames calling different partials: +## Registering multiple engines + +Registering multiple engines with different configurations is supported. They are dinguished via their `propertyName`: + ```js -fastify.register(require('../index'), { +fastify.register(require("point-of-view"), { engine: { ejs: ejs }, - layout: './templates/layout-mobile.ejs' - propertyName: 'mobile' -}) -fastify.register(require('../index'), { + layout: "./templates/layout-mobile.ejs", + propertyName: "mobile", +}); + +fastify.register(require("point-of-view"), { engine: { ejs: ejs }, - layout: './templates/layout-desktop.ejs' - propertyName: 'desktop' -}) + layout: "./templates/layout-desktop.ejs", + propertyName: "desktop", +}); + +fastify.get("/mobile", (req, reply) => { + // Render using the `mobile` render function + return reply.mobile("/templates/index.ejs", { text: "text" }); +}); + +fastify.get("/desktop", (req, reply) => { + // Render using the `desktop` render function + return reply.desktop("/templates/index.ejs", { text: "text" }); +}); ``` -If you want to set a fixed templates folder, or pass some options to the template engines: +## Providing a layout on render + +Point-of-view supports layouts for **EJS**, **Handlebars**, **Eta** and **doT**. +These engines also support providing a layout on render. + +**Please note:** Global layouts and provding layouts on render are mutually exclusive. They can not be mixed. + ```js -fastify.register(require('point-of-view'), { - engine: { - ejs: require('ejs') - }, - root: path.join(__dirname, 'view'), - layout: 'template', - viewExt: 'html', // it will add the extension to all the views - options: {} +fastify.get('/', (req, reply) => { + reply.view('index-for-layout.ejs', data, { layout: 'layout.html' }) }) ``` -If you want to set a default context that the variable can be using in each view: +## Setting request-global variables +Sometimes, several templates should have access to the same request-sceific variables. E.g. when setting the current username. + +If you want to provide data, which will be depended on by a request and available in all views, you have to add property `locals` to `reply` object, like in the example below: + ```js -fastify.register(require('point-of-view'), { - engine: { - ejs: require('ejs') - }, - defaultContext: { - dev: process.env.NODE_ENV === 'development' +fastify.addHook("preHandler", function (request, reply, done) { + reply.locals = { + text: getTextFromRequest(request), // it will be available in all views + }; + + done(); +}); +``` + +Properties from `reply.locals` will override those from `defaultContext`, but not from `data` parameter provided to `reply.view(template, data)` function. + +## Minifying HTML on render + +To utilize [`html-minifier`](https://www.npmjs.com/package/html-minifier) in the rendering process, you can add the option `useHtmlMinifier` with a reference to `html-minifier`, +and the optional `htmlMinifierOptions` option is used to specify the `html-minifier` options: + +```js +// get a reference to html-minifier +const minifier = require('html-minifier') +// optionally defined the html-minifier options +const minifierOpts = { + removeComments: true, + removeCommentsFromCDATA: true, + collapseWhitespace: true, + collapseBooleanAttributes: true, + removeAttributeQuotes: true, + removeEmptyAttributes: true +} +// in template engine options configure the use of html-minifier + options: { + useHtmlMinifier: minifier, + htmlMinifierOptions: minifierOpts } -}) ``` -and in the template files like pug can use the variable like: -```html -link(src=dev?"link-to-dev.css":"link-to-pro.css") + +To utilize [`html-minify-stream`](https://www.npmjs.com/package/html-minify-stream) in the rendering process with template engines that support streams, +you can add the option `useHtmlMinifyStream` with a reference to `html-minify-stream`, and the optional `htmlMinifierOptions` option is used to specify the options just like `html-minifier`: + +```js +// get a reference to html-minify-stream +const htmlMinifyStream = require('html-minify-stream') +// optionally defined the html-minifier options that are used by html-minify-stream +const minifierOpts = { + removeComments: true, + removeCommentsFromCDATA: true, + collapseWhitespace: true, + collapseBooleanAttributes: true, + removeAttributeQuotes: true, + removeEmptyAttributes: true +} +// in template engine options configure the use of html-minify-stream + options: { + useHtmlMinifyStream: htmlMinifyStream, + htmlMinifierOptions: minifierOpts + } ``` -Note that the data passing to the template will **override** the defaultContext -If you want to omit view extension, you can add `includeViewExtension` property as following: -```javascript -fastify.register(require('point-of-view'), { - engine: { - ejs: require('ejs') - }, - includeViewExtension: true -}); -fastify.get('/', (req, reply) => { - reply.view('/templates/index', { text: 'text' }) -}) -``` -Note that to use include files with ejs you also need: +## Engine-specific settings + + + +### Mustache To use partials in mustache you will need to pass the names and paths in the options parameter: + ```js options: { partials: { @@ -163,7 +280,10 @@ To use partials in mustache you will need to pass the names and paths in the opt } ``` +### Handlebars + To use partials in handlebars you will need to pass the names and paths in the options parameter: + ```js options: { partials: { @@ -174,204 +294,162 @@ To use partials in handlebars you will need to pass the names and paths in the o ``` To use layouts in handlebars you will need to pass the `layout` parameter: + ```js -fastify.register(require('point-of-view'), { +fastify.register(require("point-of-view"), { engine: { - handlebars: require('handlebars') + handlebars: require("handlebars"), }, - layout: './templates/layout.hbs' + layout: "./templates/layout.hbs", }); -fastify.get('/', (req, reply) => { - reply.view('./templates/index.hbs', { text: 'text' }) -}) +fastify.get("/", (req, reply) => { + reply.view("./templates/index.hbs", { text: "text" }); +}); ``` +### Nunjucks + To configure nunjucks environment after initialisation, you can pass callback function to options: -```js - options: { - onConfigure: (env) => { - // do whatever you want on nunjucks env - } - } +```js +options: { + onConfigure: (env) => { + // do whatever you want on nunjucks env + }; +} ``` +### Liquid + To configure liquid you need to pass the engine instance as engine option: + ```js -const { Liquid } = require('liquidjs') -const path = require('path') +const { Liquid } = require("liquidjs"); +const path = require("path"); const engine = new Liquid({ - root: path.join(__dirname, 'templates'), - extname: '.liquid' -}) + root: path.join(__dirname, "templates"), + extname: ".liquid", +}); -fastify.register(require('point-of-view'), { +fastify.register(require("point-of-view"), { engine: { - liquid: engine - } + liquid: engine, + }, }); -fastify.get('/', (req, reply) => { - reply.view('./templates/index.liquid', { text: 'text' }) -}) +fastify.get("/", (req, reply) => { + reply.view("./templates/index.liquid", { text: "text" }); +}); ``` +### doT + When using [doT](https://github.com/olado/doT) the plugin compiles all templates when the application starts, this way all `.def` files are loaded and both `.jst` and `.dot` files are loaded as in-memory functions. This behaviour is recommended by the doT team [here](https://github.com/olado/doT#security-considerations). To make it possible it is necessary to provide a `root` or `templates` option with the path to the template directory. + ```js -const path = require('path') +const path = require("path"); -fastify.register(require('point-of-view'), { +fastify.register(require("point-of-view"), { engine: { - dot: require('dot') + dot: require("dot"), }, - root: 'templates', + root: "templates", options: { - destination: 'dot-compiled' // path where compiled .jst files are placed (default = 'out') - } + destination: "dot-compiled", // path where compiled .jst files are placed (default = 'out') + }, }); -fastify.get('/', (req, reply) => { +fastify.get("/", (req, reply) => { // this works both for .jst and .dot files - reply.view('index', { text: 'text' }) -}) -``` - -To utilize [`html-minifier`](https://www.npmjs.com/package/html-minifier) in the rendering process, you can add the option `useHtmlMinifier` with a reference to `html-minifier`, - and the optional `htmlMinifierOptions` option is used to specify the `html-minifier` options: -```js -// get a reference to html-minifier -const minifier = require('html-minifier') -// optionally defined the html-minifier options -const minifierOpts = { - removeComments: true, - removeCommentsFromCDATA: true, - collapseWhitespace: true, - collapseBooleanAttributes: true, - removeAttributeQuotes: true, - removeEmptyAttributes: true -} -// in template engine options configure the use of html-minifier - options: { - useHtmlMinifier: minifier, - htmlMinifierOptions: minifierOpts - } -``` -To utilize [`html-minify-stream`](https://www.npmjs.com/package/html-minify-stream) in the rendering process with template engines that support streams, - you can add the option `useHtmlMinifyStream` with a reference to `html-minify-stream`, and the optional `htmlMinifierOptions` option is used to specify the options just like `html-minifier`: -```js -// get a reference to html-minify-stream -const htmlMinifyStream = require('html-minify-stream') -// optionally defined the html-minifier options that are used by html-minify-stream -const minifierOpts = { - removeComments: true, - removeCommentsFromCDATA: true, - collapseWhitespace: true, - collapseBooleanAttributes: true, - removeAttributeQuotes: true, - removeEmptyAttributes: true -} -// in template engine options configure the use of html-minify-stream - options: { - useHtmlMinifyStream: htmlMinifyStream, - htmlMinifierOptions: minifierOpts - } -``` - -The optional boolean property `production` will override environment variable `NODE_ENV` and force `point-of-view` into `production` or `development` mode: -```js - options: { - // force production mode - production: true - } + reply.view("index", { text: "text" }); +}); ``` -If you want to provide data, which will be depended on by a request and available in all views, you have to add property `locals` to `reply` object, like in the example below: -```js -fastify.addHook('preHandler', function (request, reply, done) { - reply.locals = { - text: getTextFromRequest(request) // it will be available in all views - } - - done() -}) -``` -Properties from `reply.locals` will override those from `defaultContext`, but not from `data` parameter provided to `reply.view(template, data)` function. + + +### Miscellaneous + +## Using point-of-view as a dependency in a fastify-plugin To require `point-of-view` as a dependency to a [fastify-plugin](https://github.com/fastify/fastify-plugin), add the name `point-of-view` to the dependencies array in the [plugin's opts](https://github.com/fastify/fastify-plugin#dependencies). ```js -fastify.register(myViewRendererPlugin, { - dependencies: ['point-of-view'] -}) +fastify.register(myViewRendererPlugin, { + dependencies: ["point-of-view"], +}); ``` +## Forcing a cache-flush + To forcefully clear cache when in production mode, call the `view.clearCache()` function. ```js -fastify.view.clearCache() +fastify.view.clearCache(); ``` + ## Note By default views are served with the mime type 'text/html; charset=utf-8', but you can specify a different value using the type function of reply, or by specifying the desired charset in the property 'charset' in the opts object given to the plugin. - ## Acknowledgements This project is kindly sponsored by: + - [nearForm](https://nearform.com) - [LetzDoIt](https://www.letzdoitapp.com/) ## License Licensed under [MIT](./LICENSE). - diff --git a/index.js b/index.js index 3d5d5839..af400fbd 100644 --- a/index.js +++ b/index.js @@ -27,38 +27,47 @@ function fastifyView (fastify, opts, next) { const charset = opts.charset || 'utf-8' const propertyName = opts.propertyName || 'view' const engine = opts.engine[type] - const options = opts.options || {} + const globalOptions = opts.options || {} const templatesDir = opts.root || resolve(opts.templates || './') 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 layoutFileName = opts.layout + const globalLayoutFileName = opts.layout - if (layoutFileName && type !== 'dot' && type !== 'handlebars' && type !== 'ejs' && type !== 'eta') { - next(new Error('Only Dot, Handlebars, EJS, and Eta support the "layout" option')) - return + 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}"`) + } } - if (layoutFileName && !hasAccessToLayoutFile(layoutFileName, getDefaultExtension(type))) { - next(new Error(`unable to access template "${layoutFileName}"`)) - return + if (globalLayoutFileName) { + try { + layoutIsValid(globalLayoutFileName) + } catch (error) { + next(error) + return + } } - const dotRender = type === 'dot' ? viewDot.call(fastify, preProcessDot.call(fastify, templatesDir, options)) : null + const dotRender = type === 'dot' ? viewDot.call(fastify, preProcessDot.call(fastify, templatesDir, globalOptions)) : null const renders = { marko: viewMarko, - ejs: withLayout(viewEjs), - handlebars: withLayout(viewHandlebars), + ejs: withLayout(viewEjs, globalLayoutFileName), + handlebars: withLayout(viewHandlebars, globalLayoutFileName), mustache: viewMustache, nunjucks: viewNunjucks, 'art-template': viewArtTemplate, twig: viewTwig, liquid: viewLiquid, - dot: withLayout(dotRender), - eta: withLayout(viewEta), + dot: withLayout(dotRender, globalLayoutFileName), + eta: withLayout(viewEta, globalLayoutFileName), _default: view } @@ -153,8 +162,8 @@ function fastifyView (fastify, opts, next) { callback(err, null) return } - if (options.useHtmlMinifier && (typeof options.useHtmlMinifier.minify === 'function')) { - data = options.useHtmlMinifier.minify(data, options.htmlMinifierOptions || {}) + if (globalOptions.useHtmlMinifier && (typeof globalOptions.useHtmlMinifier.minify === 'function')) { + data = globalOptions.useHtmlMinifier.minify(data, globalOptions.htmlMinifierOptions || {}) } if (type === 'handlebars') { data = engine.compile(data) @@ -186,8 +195,8 @@ function fastifyView (fastify, opts, next) { if (err) { error = err } - if (options.useHtmlMinifier && (typeof options.useHtmlMinifier.minify === 'function')) { - data = options.useHtmlMinifier.minify(data, options.htmlMinifierOptions || {}) + if (globalOptions.useHtmlMinifier && (typeof globalOptions.useHtmlMinifier.minify === 'function')) { + data = globalOptions.useHtmlMinifier.minify(data, globalOptions.htmlMinifierOptions || {}) } partialsHtml[key] = data @@ -209,15 +218,15 @@ function fastifyView (fastify, opts, next) { let compiledPage try { - if ((type === 'ejs') && viewExt && !options.includer) { - options.includer = (originalPath, parsedPath) => { + if ((type === 'ejs') && viewExt && !globalOptions.includer) { + globalOptions.includer = (originalPath, parsedPath) => { return { filename: parsedPath || join(templatesDir, originalPath + '.' + viewExt) } } } - options.filename = join(templatesDir, page) - compiledPage = engine.compile(html, options) + globalOptions.filename = join(templatesDir, page) + compiledPage = engine.compile(html, globalOptions) } catch (error) { that.send(error) return @@ -233,8 +242,8 @@ function fastifyView (fastify, opts, next) { } catch (error) { cachedPage = error } - if (options.useHtmlMinifier && (typeof options.useHtmlMinifier.minify === 'function')) { - cachedPage = options.useHtmlMinifier.minify(cachedPage, options.htmlMinifierOptions || {}) + if (globalOptions.useHtmlMinifier && (typeof globalOptions.useHtmlMinifier.minify === 'function')) { + cachedPage = globalOptions.useHtmlMinifier.minify(cachedPage, globalOptions.htmlMinifierOptions || {}) } that.send(cachedPage) } @@ -291,7 +300,18 @@ function fastifyView (fastify, opts, next) { readFile(join(templatesDir, page), 'utf8', readCallback(this, page, data)) } - function viewEjs (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 @@ -331,7 +351,7 @@ function fastifyView (fastify, opts, next) { } // merge engine options - const confs = Object.assign({}, defaultSetting, options) + const confs = Object.assign({}, defaultSetting, globalOptions) function render (filename, data) { confs.filename = join(templatesDir, filename) @@ -355,17 +375,17 @@ function fastifyView (fastify, opts, next) { this.send(new Error('Missing page')) return } - const env = engine.configure(templatesDir, options) - if (typeof options.onConfigure === 'function') { - options.onConfigure(env) + const env = engine.configure(templatesDir, globalOptions) + if (typeof globalOptions.onConfigure === 'function') { + globalOptions.onConfigure(env) } data = Object.assign({}, defaultCtx, this.locals, data) // Append view extension. page = getPage(page, 'njk') env.render(join(templatesDir, page), data, (err, html) => { if (err) return this.send(err) - if (options.useHtmlMinifier && (typeof options.useHtmlMinifier.minify === 'function')) { - html = options.useHtmlMinifier.minify(html, options.htmlMinifierOptions || {}) + if (globalOptions.useHtmlMinifier && (typeof globalOptions.useHtmlMinifier.minify === 'function')) { + html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {}) } this.header('Content-Type', 'text/html; charset=' + charset) this.send(html) @@ -389,8 +409,8 @@ function fastifyView (fastify, opts, next) { const template = opts && opts.templateSrc ? engine.load(join(templatesDir, page), opts.templateSrc) : engine.load(join(templatesDir, page)) if (opts && opts.stream) { - if (typeof options.useHtmlMinifyStream === 'function') { - this.send(template.stream(data).pipe(options.useHtmlMinifyStream(options.htmlMinifierOptions || {}))) + if (typeof globalOptions.useHtmlMinifyStream === 'function') { + this.send(template.stream(data).pipe(globalOptions.useHtmlMinifyStream(globalOptions.htmlMinifierOptions || {}))) } else { this.send(template.stream(data)) } @@ -401,8 +421,8 @@ function fastifyView (fastify, opts, next) { function send (that) { return function _send (err, html) { if (err) return that.send(err) - if (options.useHtmlMinifier && (typeof options.useHtmlMinifier.minify === 'function')) { - html = options.useHtmlMinifier.minify(html, options.htmlMinifierOptions || {}) + if (globalOptions.useHtmlMinifier && (typeof globalOptions.useHtmlMinifier.minify === 'function')) { + html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {}) } that.header('Content-Type', 'text/html; charset=' + charset) that.send(html) @@ -410,13 +430,24 @@ function fastifyView (fastify, opts, next) { } } - function viewHandlebars (page, data) { + 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({}, opts.options) + const options = Object.assign({}, globalOptions) data = Object.assign({}, defaultCtx, this.locals, data) // append view extension page = getPage(page, 'hbs') @@ -498,7 +529,7 @@ function fastifyView (fastify, opts, next) { return } - data = Object.assign({}, defaultCtx, options, this.locals, data) + data = Object.assign({}, defaultCtx, globalOptions, this.locals, data) // Append view extension. page = getPage(page, 'twig') engine.renderFile(join(templatesDir, page), data, (err, html) => { @@ -506,8 +537,8 @@ function fastifyView (fastify, opts, next) { return this.send(err) } - if (options.useHtmlMinifier && (typeof options.useHtmlMinifier.minify === 'function')) { - html = options.useHtmlMinifier.minify(html, options.htmlMinifierOptions || {}) + if (globalOptions.useHtmlMinifier && (typeof globalOptions.useHtmlMinifier.minify === 'function')) { + html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {}) } if (!this.getHeader('content-type')) { this.header('Content-Type', 'text/html; charset=' + charset) @@ -528,8 +559,8 @@ function fastifyView (fastify, opts, next) { engine.renderFile(join(templatesDir, page), data, opts) .then((html) => { - if (options.useHtmlMinifier && (typeof options.useHtmlMinifier.minify === 'function')) { - html = options.useHtmlMinifier.minify(html, options.htmlMinifierOptions || {}) + if (globalOptions.useHtmlMinifier && (typeof globalOptions.useHtmlMinifier.minify === 'function')) { + html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {}) } if (!this.getHeader('content-type')) { this.header('Content-Type', 'text/html; charset=' + charset) @@ -543,14 +574,24 @@ function fastifyView (fastify, opts, next) { 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) - if (options.useHtmlMinifier && (typeof options.useHtmlMinifier.minify === 'function')) { - html = options.useHtmlMinifier.minify(html, options.htmlMinifierOptions || {}) + if (globalOptions.useHtmlMinifier && (typeof globalOptions.useHtmlMinifier.minify === 'function')) { + html = globalOptions.useHtmlMinifier.minify(html, globalOptions.htmlMinifierOptions || {}) } if (!this.getHeader('content-type')) { this.header('Content-Type', 'text/html; charset=' + charset) @@ -559,7 +600,18 @@ function fastifyView (fastify, opts, next) { } } - function viewEta (page, data) { + 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 @@ -567,13 +619,13 @@ function fastifyView (fastify, opts, next) { lru.define = lru.set engine.configure({ - templates: options.templates ? options.templates : lru + templates: globalOptions.templates ? globalOptions.templates : lru }) const config = Object.assign({ cache: prod, views: templatesDir - }, options) + }, globalOptions) data = Object.assign({}, defaultCtx, this.locals, data) // Append view extension (Eta will append '.eta' by default, @@ -595,8 +647,8 @@ function fastifyView (fastify, opts, next) { }) } - if (prod && type === 'handlebars' && options.partials) { - getPartials(type, options.partials, (err, partialsObject) => { + if (prod && type === 'handlebars' && globalOptions.partials) { + getPartials(type, globalOptions.partials, (err, partialsObject) => { if (err) { next(err) return @@ -610,13 +662,12 @@ function fastifyView (fastify, opts, next) { next() } - function withLayout (render) { - if (layoutFileName) { + 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: () => { }, @@ -626,13 +677,11 @@ function fastifyView (fastify, opts, next) { } data = Object.assign((data || {}), { body: result }) - - render.call(that, layoutFileName, data, opts) + render.call(that, layout, data, opts) } }, page, data, opts) } } - return render } diff --git a/package.json b/package.json index ab803fa5..e6625a6a 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,20 @@ "hashlru": "^2.3.0" }, "devDependencies": { - "@types/node": "^16.0.0", + "@types/node": "^16.11.10", + "@typescript-eslint/eslint-plugin": "^5.4.0", + "@typescript-eslint/parser": "^5.4.0", "art-template": "^4.13.2", "cross-env": "^7.0.2", "dot": "^1.1.3", "ejs": "^3.1.2", + "eslint": "^7.32.0", + "eslint-config-standard": "^16.0.3", + "eslint-import-resolver-node": "^0.3.6", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^5.1.1", + "eslint-plugin-standard": "^5.0.0", "eta": "^1.11.0", "express": "^4.17.1", "fastify": "^3.0.0", diff --git a/test/test-dot.js b/test/test-dot.js index 316adec5..352e8985 100644 --- a/test/test-dot.js +++ b/test/test-dot.js @@ -482,3 +482,101 @@ test('reply.view with dot engine with layout option', t => { }) }) }) + +test('reply.view with dot engine with layout option on render', t => { + t.plan(6) + const fastify = Fastify() + const engine = require('dot') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + dot: engine + }, + root: 'templates' + }) + + fastify.get('/', (req, reply) => { + reply.view('testdot', data, { layout: 'layout' }) + }) + + fastify.listen(0, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal('header: textfoo text1

foo

footer', body.toString()) + fastify.close() + }) + }) +}) + +test('reply.view with dot engine with layout option on render', t => { + t.plan(6) + const fastify = Fastify() + const engine = require('dot') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + dot: engine + }, + root: 'templates' + }) + + fastify.get('/', (req, reply) => { + reply.view('testdot', data, { layout: 'layout' }) + }) + + fastify.listen(0, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal('header: textfoo text1

foo

footer', body.toString()) + fastify.close() + }) + }) +}) + +test('reply.view should return 500 if layout is missing on render', t => { + t.plan(3) + const fastify = Fastify() + const engine = require('dot') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + dot: engine + }, + root: 'templates' + }) + + fastify.get('/', (req, reply) => { + reply.view('testdot', data, { layout: 'non-existing-layout' }) + }) + + fastify.listen(0, err => { + t.error(err) + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 500) + fastify.close() + }) + }) +}) diff --git a/test/test-ejs.js b/test/test-ejs.js index 5af0cb6c..22729e66 100644 --- a/test/test-ejs.js +++ b/test/test-ejs.js @@ -85,6 +85,69 @@ test('reply.view with ejs engine with layout option', t => { }) }) +test('reply.view with ejs engine with layout option on render', t => { + t.plan(6) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + ejs: ejs + }, + root: path.join(__dirname, '../templates') + }) + + fastify.get('/', (req, reply) => { + reply.view('index-for-layout.ejs', data, { layout: 'layout.html' }) + }) + + fastify.listen(0, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(ejs.render(fs.readFileSync('./templates/index.ejs', 'utf8'), data), body.toString()) + fastify.close() + }) + }) +}) + +test('reply.view should return 500 if layout is missing on render', t => { + t.plan(3) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + fastify.register(require('../index'), { + engine: { + ejs + }, + root: path.join(__dirname, '../templates') + }) + + fastify.get('/', (req, reply) => { + reply.view('index-for-layout.ejs', data, { layout: 'non-existing-layout.html' }) + }) + + fastify.listen(0, err => { + t.error(err) + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 500) + fastify.close() + }) + }) +}) + test('reply.view with ejs engine and custom ext', t => { t.plan(6) const fastify = Fastify() @@ -599,6 +662,51 @@ test('*** reply.view with ejs engine with layout option, includeViewExtension pr }) }) +test('*** reply.view with ejs engine with layout option on render, includeViewExtension property as true ***', t => { + t.plan(6) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + const header = '' + const footer = '' + + fastify.register(require('../index'), { + engine: { + ejs: ejs + }, + defaultContext: { + header, + footer + }, + includeViewExtension: true, + root: path.join(__dirname, '../templates') + }) + + fastify.get('/', (req, reply) => { + reply.view('index-for-layout.ejs', data, { layout: 'layout-with-includes' }) + }) + + fastify.listen(0, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(ejs.render(fs.readFileSync('./templates/index.ejs', 'utf8'), { + ...data, + header, + footer + }), body.toString()) + fastify.close() + }) + }) +}) + test('reply.view with ejs engine, template folder specified, include files (ejs and html) used in template, includeViewExtension property as true', t => { t.plan(7) const fastify = Fastify() diff --git a/test/test-eta.js b/test/test-eta.js index 8da6be9d..b7ab9204 100644 --- a/test/test-eta.js +++ b/test/test-eta.js @@ -82,6 +82,71 @@ test('reply.view with eta engine with layout option', t => { }) }) +test('reply.view with eta engine with layout option on render', t => { + t.plan(6) + const fastify = Fastify() + + const data = { text: 'text' } + + fastify.register(pointOfView, { + engine: { + eta: eta + }, + root: path.join(__dirname, '../templates') + }) + + fastify.get('/', (req, reply) => { + reply.view('index-for-layout.eta', data, { layout: 'layout-eta.html' }) + }) + + fastify.listen(0, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(eta.render(fs.readFileSync('./templates/index.eta', 'utf8'), data), body.toString()) + fastify.close() + }) + }) +}) + +test('reply.view should return 500 if layout is missing on render', t => { + t.plan(3) + const fastify = Fastify() + + const data = { text: 'text' } + + fastify.register(pointOfView, { + engine: { + eta: eta + }, + root: path.join(__dirname, '../templates') + }) + + fastify.get('/', (req, reply) => { + reply.view('index-for-layout.eta', data, { layout: 'non-existing-layout-eta.html' }) + }) + + fastify.listen(0, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 500) + fastify.close() + }) + }) +}) + test('reply.view with eta engine and custom ext', t => { t.plan(6) const fastify = Fastify() diff --git a/test/test-handlebars.js b/test/test-handlebars.js index aad1813b..671dd2c6 100644 --- a/test/test-handlebars.js +++ b/test/test-handlebars.js @@ -307,6 +307,52 @@ test('fastify.view with handlebars engine with layout option', t => { }) }) +test('fastify.view with handlebars engine with layout option on render', t => { + t.plan(3) + + const fastify = Fastify() + const handlebars = require('handlebars') + const data = { text: 'it works!' } + + fastify.register(require('../index'), { + engine: { + handlebars + } + }) + + fastify.ready(err => { + t.error(err) + + fastify.view('./templates/index-for-layout.hbs', data, { layout: './templates/layout.hbs' }, (err, compiled) => { + t.error(err) + t.equal(handlebars.compile(fs.readFileSync('./templates/index.hbs', 'utf8'))(data), compiled) + fastify.close() + }) + }) +}) + +test('fastify.view with handlebars engine with invalid layout option on render should throw', t => { + t.plan(3) + + const fastify = Fastify() + const handlebars = require('handlebars') + const data = { text: 'it works!' } + + fastify.register(require('../index'), { + engine: { + handlebars + } + }) + + fastify.ready(err => { + t.error(err) + fastify.view('./templates/index-for-layout.hbs', data, { layout: './templates/invalid-layout.hbs' }, (err, compiled) => { + t.ok(err instanceof Error) + t.equal(err.message, 'unable to access template "./templates/invalid-layout.hbs"') + }) + }) +}) + test('reply.view with handlebars engine', t => { t.plan(6) const fastify = Fastify() @@ -758,6 +804,65 @@ test('reply.view with handlebars engine with layout option', t => { }) }) +test('reply.view with handlebars engine with layout option on render', t => { + t.plan(6) + const fastify = Fastify() + const handlebars = require('handlebars') + + fastify.register(require('../index'), { + engine: { + handlebars: handlebars + } + }) + + fastify.get('/', (req, reply) => { + reply.view('./templates/index-for-layout.hbs', {}, { layout: './templates/layout.hbs' }) + }) + + fastify.listen(0, err => { + t.error(err) + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, replyBody) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + replyBody.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(handlebars.compile(fs.readFileSync('./templates/index.hbs', 'utf8'))({}), replyBody.toString()) + fastify.close() + }) + }) +}) + +test('reply.view should return 500 if layout is missing on render', t => { + t.plan(3) + const fastify = Fastify() + const handlebars = require('handlebars') + + fastify.register(require('../index'), { + engine: { + handlebars: handlebars + } + }) + + fastify.get('/', (req, reply) => { + reply.view('./templates/index-for-layout.hbs', {}, { layout: './templates/missing-layout.hbs' }) + }) + + fastify.listen(0, err => { + t.error(err) + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, replyBody) => { + t.error(err) + t.equal(response.statusCode, 500) + fastify.close() + }) + }) +}) + test('fastify.view with handlebars engine, missing template file', t => { t.plan(3) const fastify = Fastify() diff --git a/test/test.js b/test/test.js index 6133f70a..a8f48b5c 100644 --- a/test/test.js +++ b/test/test.js @@ -325,6 +325,35 @@ test('reply.view should return 500 if page is missing', t => { }) }) +test('reply.view should return 500 if layout is set globally and provided on render', t => { + t.plan(3) + const fastify = Fastify() + const data = { text: 'text' } + fastify.register(require('../index'), { + engine: { + ejs: require('ejs'), + layout: 'layout.html' + } + }) + + fastify.get('/', (req, reply) => { + reply.view('index-for-layout.ejs', data, { layout: 'layout.html' }) + }) + + fastify.listen(0, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 500) + fastify.close() + }) + }) +}) + test('register callback should throw if the engine is missing', t => { t.plan(2) const fastify = Fastify()