├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── package.json ├── LICENSE ├── types ├── plugin.test-d.ts └── plugin.d.ts ├── index.js ├── examples └── app.js ├── .gitignore ├── README.md └── test.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: standard 10 | versions: 11 | - 16.0.3 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-html", 3 | "version": "0.7.1", 4 | "description": "Fastify HTML/HTMX plugin", 5 | "type": "module", 6 | "main": "index.js", 7 | "types": "types/plugin.d.ts", 8 | "scripts": { 9 | "lint": "standard | snazzy", 10 | "test": "c8 --100 node --test test.js && tsd" 11 | }, 12 | "pre-commit": [ 13 | "lint", 14 | "test" 15 | ], 16 | "keywords": [ 17 | "fastify", 18 | "html", 19 | "htmx" 20 | ], 21 | "author": "Matteo Collina ", 22 | "license": "MIT", 23 | "dependencies": { 24 | "fastify-plugin": "^5.0.0", 25 | "ghtml": "^3.0.6" 26 | }, 27 | "devDependencies": { 28 | "@fastify/cookie": "^11.0.1", 29 | "@fastify/formbody": "^8.0.0", 30 | "@fastify/pre-commit": "^2.1.0", 31 | "@types/node": "^22.1.0", 32 | "c8": "^10.0.0", 33 | "fastify": "^5.0.0-alpha.4", 34 | "snazzy": "^9.0.0", 35 | "standard": "^17.1.0", 36 | "tsd": "^0.33.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'docs/**' 7 | - '*.md' 8 | pull_request: 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x, 22.x] 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Use Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Install 29 | run: | 30 | npm install 31 | 32 | - name: Lint 33 | run: | 34 | npm run lint 35 | 36 | - name: Run tests 37 | run: | 38 | npm run test 39 | 40 | automerge: 41 | needs: test 42 | runs-on: ubuntu-latest 43 | 44 | permissions: 45 | pull-requests: write 46 | contents: write 47 | 48 | steps: 49 | - uses: fastify/github-action-merge-dependabot@v3 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Matteo Collina 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /types/plugin.test-d.ts: -------------------------------------------------------------------------------- 1 | import fastifyHtml from '..'; 2 | import { expectType } from 'tsd'; 3 | import * as fastifyHtmlStar from '..'; 4 | import fastify, { FastifyInstance, FastifyReply } from 'fastify'; 5 | 6 | const app: FastifyInstance = fastify(); 7 | app.register(fastifyHtml); 8 | 9 | const server = fastify(); 10 | 11 | server.register(fastifyHtml, { async: false }); 12 | 13 | server.after(() => { 14 | // Testing the 'html' method on Fastify instance 15 | expectType<(strings: TemplateStringsArray, ...values: any[]) => string>(server.html); 16 | 17 | // Testing the 'addLayout' method on Fastify instance 18 | expectType<( 19 | render: (htmlString: string, context: FastifyReply) => string, 20 | options?: { skipOnHeader?: string } 21 | ) => void>(server.addLayout); 22 | 23 | // Testing the 'html' method on Fastify reply 24 | server.get('/', (request, reply) => { 25 | expectType<(strings: TemplateStringsArray, ...values: any[]) => FastifyReply>( 26 | reply.html 27 | ); 28 | 29 | reply.html`

Hello World

`; 30 | }); 31 | }); 32 | 33 | const serverWithPlugin = fastify(); 34 | 35 | serverWithPlugin.register(fastifyHtml); 36 | 37 | serverWithPlugin.after(() => { 38 | serverWithPlugin.get('/', (request, reply) => { 39 | reply.html`

Hello World

`; 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /types/plugin.d.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback, FastifyInstance, FastifyReply } from 'fastify'; 2 | 3 | declare module 'fastify' { 4 | interface FastifyInstance { 5 | /** 6 | * Function to render HTML. 7 | * @param strings Template strings. 8 | * @param values Values to be injected into the template. 9 | */ 10 | html(strings: TemplateStringsArray, ...values: any[]): string; 11 | 12 | /** 13 | * Adds a layout to the Fastify instance. 14 | * @param render The function that renders the layout. 15 | * @param options Options for the layout. 16 | */ 17 | addLayout( 18 | render: (inner: string, context: FastifyReply) => string, 19 | options?: { skipOnHeader?: string } 20 | ): void; 21 | } 22 | 23 | interface FastifyReply { 24 | /** 25 | * Function to render and send HTML with layouts. 26 | * @param strings Template strings. 27 | * @param values Values to be injected into the template. 28 | */ 29 | html(strings: TemplateStringsArray, ...values: any[]): FastifyReply; 30 | } 31 | } 32 | 33 | type FastifyHtmlPlugin = FastifyPluginCallback; 34 | 35 | declare namespace fastifyHtml { 36 | export interface FastifyHtmlOptions { 37 | async?: boolean; 38 | } 39 | 40 | export const fastifyHtml: FastifyHtmlPlugin; 41 | export { fastifyHtml as default } 42 | } 43 | 44 | declare function fastifyHtml ( 45 | ...params: Parameters 46 | ): ReturnType 47 | 48 | export = fastifyHtml; 49 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import { html, htmlAsyncGenerator } from 'ghtml' 3 | import { Readable } from 'node:stream' 4 | 5 | const kLayout = Symbol('fastifyHtmlLayout') 6 | 7 | function plugin (fastify, opts) { 8 | fastify.decorate('html', opts.async ? htmlAsyncGenerator : html) 9 | fastify.decorate(kLayout, null) 10 | 11 | fastify.decorate('addLayout', function (render, { skipOnHeader } = {}) { 12 | // Using a symbol attached to `this` and a stack allows us to 13 | // support nested layouts with encapsulated plugins. 14 | const layout = { 15 | render, 16 | parent: this[kLayout], 17 | skipOnHeader 18 | } 19 | 20 | this[kLayout] = layout 21 | }) 22 | 23 | fastify.decorateReply('html', function (strings, ...values) { 24 | let htmlString = fastify.html(strings, ...values) 25 | let layout = this.server[kLayout] 26 | 27 | // render each layout in the stack 28 | // using a while loop instead of recursion 29 | // to avoid stack overflows and reduce memory usage 30 | while (layout) { 31 | if (layout.skipOnHeader && this.request.headers[layout.skipOnHeader]) { 32 | layout = layout.parent 33 | continue 34 | } 35 | htmlString = layout.render(htmlString, this) 36 | layout = layout.parent 37 | } 38 | 39 | this.header('Content-Type', 'text/html; charset=utf-8') 40 | this.send(opts.async ? Readable.from(htmlString) : htmlString) 41 | return this 42 | }) 43 | } 44 | 45 | const fastifyHtml = fp(plugin, { 46 | fastify: '5.x', 47 | name: 'fastify-html' 48 | }) 49 | 50 | export default fastifyHtml 51 | -------------------------------------------------------------------------------- /examples/app.js: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify' 2 | import fastifyHtml from '../index.js' 3 | import formBody from '@fastify/formbody' 4 | import cookies from '@fastify/cookie' 5 | 6 | // const app = fastify({ logger: true }) 7 | const app = fastify() 8 | await app.register(formBody) 9 | await app.register(fastifyHtml) 10 | await app.register(cookies) 11 | 12 | app.addLayout(function (inner, reply) { 13 | return app.html` 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | !${inner} 22 | 23 | 24 | ` 25 | }, { skipOnHeader: 'hx-request' }) 26 | 27 | function status (html, counter) { 28 | return html`

Status: ${counter}

` 29 | } 30 | 31 | app.get('/', async (req, reply) => { 32 | const name = req.query.name || 'World' 33 | const counter = req.cookies.counter || 0 34 | return reply.html` 35 |

Hello ${name}

36 | !${status(app.html, counter)} 37 | 40 | 43 | ` 44 | }) 45 | 46 | app.post('/up', async (req, reply) => { 47 | let counter = req.cookies.counter || 0 48 | reply.setCookie('counter', ++counter) 49 | return status(reply.html.bind(reply), counter) 50 | }) 51 | 52 | app.post('/down', async (req, reply) => { 53 | let counter = req.cookies.counter || 0 54 | reply.setCookie('counter', --counter) 55 | return status(reply.html.bind(reply), counter) 56 | }) 57 | 58 | await app.listen({ port: 3000 }) 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | .pnpm-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | .clinic 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-html 2 | 3 | Generate html in the most natural Fastify way, using template tags, 4 | layouts and the plugin system. 5 | 6 | Template expressions are escaped by default unless they are prefixed with `!`. 7 | 8 | ## Install 9 | 10 | ```bash 11 | npm i fastify fastify-html 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```js 17 | import fastify from 'fastify' 18 | import fastifyHtml from 'fastify-html' 19 | 20 | const app = fastify() 21 | await app.register(fastifyHtml) 22 | 23 | app.addLayout(function (inner, reply) { 24 | return app.html` 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | !${inner} 33 | 34 | 35 | ` 36 | }, { skipOnHeader: 'hx-request' }) 37 | 38 | app.get('/', async (req, reply) => { 39 | const name = req.query.name || 'World' 40 | return reply.html`

Hello ${name}

` 41 | }) 42 | 43 | app.get('/complex-response/:page', async (req, reply) => { 44 | const name = req.query.name || 'World' 45 | const userInfo = await getUserInfo(name) || {} 46 | const demand = req.query.demand 47 | 48 | return reply.html` 49 |
50 | Welcome, ${name}. 51 |

52 | 53 | User information: 54 |
55 | 56 | !${Object.keys(userInfo).map( 57 | (key) => app.html` 58 | ${key}: ${userInfo[key]} 59 |
60 | ` 61 | )} 62 |
63 | 64 | !${ 65 | demand 66 | ? app.html` 67 |

Your demand: ${demand}

68 | ` 69 | : "" 70 | } 71 |
72 | ` 73 | }) 74 | 75 | await app.listen({ port: 3000 }) 76 | 77 | async function getUserInfo(name) { 78 | return { age: 25, location: "Earth" }; 79 | } 80 | ``` 81 | 82 | ## Async Mode Usage 83 | 84 | ```js 85 | import fastify from 'fastify' 86 | import fastifyHtml from 'fastify-html' 87 | import { createReadStream } from 'node:fs' 88 | 89 | const app = fastify() 90 | await app.register(fastifyHtml, { async: true }) 91 | 92 | app.addLayout(function (inner, reply) { 93 | return app.html` 94 | 95 | 96 | 97 | 98 | 99 | 100 | !${inner} 101 | 102 | 103 | ` 104 | }, { skipOnHeader: 'hx-request' }) 105 | 106 | app.get('/:name', async (req, reply) => { 107 | return reply.html` 108 |
109 | Welcome, ${req.params.name}. 110 |

111 | 112 | User information: 113 |
114 | 115 | 116 | !${getUserInfoPromise(req.params.name)} 117 |
118 | 119 | 120 |
121 | File content: 122 |
123 | !${createReadStream('./path/to/file.txt')} 124 |
125 |
126 | ` 127 | }) 128 | 129 | await app.listen({ port: 3000 }) 130 | 131 | async function getUserInfoPromise(name) { 132 | return new Promise((resolve) => { 133 | setTimeout(() => { 134 | resolve({ age: 25, location: "Earth" }); 135 | }, 1000); 136 | }); 137 | } 138 | ``` 139 | 140 | ### Plugins 141 | 142 | Encapsulation is supported and respected for layouts, meaning that `addLayout` 143 | calls will be not exposed to the parent plugin, like the following: 144 | 145 | ```js 146 | import fastify from 'fastify' 147 | import fastifyHtml from 'fastify-html' 148 | 149 | const app = fastify() 150 | await app.register(fastifyHtml) 151 | 152 | app.addLayout(function (inner, reply) { 153 | return app.html` 154 | 155 | 156 | 157 | !${inner} 158 | 159 | 160 | ` 161 | }) 162 | 163 | app.get('/', async (req, reply) => { 164 | const name = req.query.name || 'World' 165 | strictEqual(reply.html`

Hello ${name}

`, reply) 166 | return reply 167 | }) 168 | 169 | app.register(async function (app) { 170 | app.addLayout(function (inner) { 171 | return app.html` 172 | 173 | !${inner} 174 | 175 | ` 176 | }) 177 | 178 | app.get('/nested', async (req, reply) => { 179 | const name = req.query.name || 'World' 180 | return reply.html`

Nested ${name}

` 181 | }) 182 | }) 183 | 184 | await app.listen({ port: 3000 }) 185 | ``` 186 | 187 | ## Options 188 | 189 | - `async`: Enables async mode for handling asynchronous template expressions. Set this option to true when registering the fastify-html plugin to take advantage of features like promise resolution, stream handling, and async generator support. 190 | 191 | ## License 192 | 193 | MIT 194 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import fastify from 'fastify' 3 | import fastifyHtml from './index.js' 4 | import { strictEqual } from 'node:assert' 5 | import { readFile } from 'node:fs/promises' 6 | import { readFileSync, createReadStream } from 'node:fs' 7 | 8 | const README = readFileSync('README.md', 'utf8') 9 | 10 | const escapeDictionary = { 11 | '"': '"', 12 | '&': '&', 13 | "'": ''', 14 | '<': '<', 15 | '=': '=', 16 | '>': '>' 17 | } 18 | 19 | test('render html', async t => { 20 | const app = fastify() 21 | app.register(fastifyHtml) 22 | 23 | app.get('/', async (req, reply) => { 24 | const name = req.query.name || 'World' 25 | strictEqual(reply.html`

Hello ${name}

`, reply) 26 | return reply 27 | }) 28 | 29 | { 30 | const res = await app.inject({ 31 | method: 'GET', 32 | url: '/' 33 | }) 34 | strictEqual(res.statusCode, 200) 35 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 36 | strictEqual(res.body, '

Hello World

') 37 | } 38 | 39 | { 40 | const res = await app.inject({ 41 | method: 'GET', 42 | url: '/?name=Matteo' 43 | }) 44 | strictEqual(res.statusCode, 200) 45 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 46 | strictEqual(res.body, '

Hello Matteo

') 47 | } 48 | }) 49 | 50 | test('render html (async)', async t => { 51 | const app = fastify() 52 | app.register(fastifyHtml, { async: true }) 53 | 54 | app.get('/', async (req, reply) => { 55 | const name = req.query.name || 'World' 56 | strictEqual(reply.html`

Hello ${name}

`, reply) 57 | return reply 58 | }) 59 | 60 | { 61 | const res = await app.inject({ 62 | method: 'GET', 63 | url: '/' 64 | }) 65 | strictEqual(res.statusCode, 200) 66 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 67 | strictEqual(res.body, '

Hello World

') 68 | } 69 | 70 | { 71 | const res = await app.inject({ 72 | method: 'GET', 73 | url: '/?name=Matteo' 74 | }) 75 | strictEqual(res.statusCode, 200) 76 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 77 | strictEqual(res.body, '

Hello Matteo

') 78 | } 79 | }) 80 | 81 | test('one level layout', async t => { 82 | const app = fastify() 83 | await app.register(fastifyHtml) 84 | 85 | app.addLayout(function (inner) { 86 | return app.html` 87 | 88 | 89 | 90 | !${inner} 91 | 92 | 93 | ` 94 | }) 95 | 96 | app.get('/', async (req, reply) => { 97 | const name = req.query.name || 'World' 98 | strictEqual(reply.html`

Hello ${name}

`, reply) 99 | return reply 100 | }) 101 | 102 | { 103 | const res = await app.inject({ 104 | method: 'GET', 105 | url: '/' 106 | }) 107 | strictEqual(res.statusCode, 200) 108 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 109 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 110 | 111 | 112 |

Hello World

113 | 114 | `.replaceAll(' ', '').replaceAll('\n', '')) 115 | } 116 | 117 | { 118 | const res = await app.inject({ 119 | method: 'GET', 120 | url: '/?name=Matteo' 121 | }) 122 | strictEqual(res.statusCode, 200) 123 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 124 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 125 | 126 | 127 |

Hello Matteo

128 | 129 | `.replaceAll(' ', '').replaceAll('\n', '')) 130 | } 131 | }) 132 | 133 | test('one level layout (async)', async t => { 134 | const app = fastify() 135 | await app.register(fastifyHtml, { async: true }) 136 | 137 | app.setErrorHandler(function (error, req, reply) { 138 | console.error(error) 139 | }) 140 | 141 | app.addLayout(function (inner) { 142 | return app.html` 143 | 144 | 145 | 146 | !${inner} 147 | 148 | 149 | ` 150 | }) 151 | 152 | app.get('/', async (req, reply) => { 153 | const name = req.query.name || 'World' 154 | strictEqual(reply.html`

Hello ${name}

`, reply) 155 | return reply 156 | }) 157 | 158 | { 159 | const res = await app.inject({ 160 | method: 'GET', 161 | url: '/' 162 | }) 163 | strictEqual(res.statusCode, 200) 164 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 165 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 166 | 167 | 168 |

Hello World

169 | 170 | `.replaceAll(' ', '').replaceAll('\n', '')) 171 | } 172 | 173 | { 174 | const res = await app.inject({ 175 | method: 'GET', 176 | url: '/?name=Matteo' 177 | }) 178 | strictEqual(res.statusCode, 200) 179 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 180 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 181 | 182 | 183 |

Hello Matteo

184 | 185 | `.replaceAll(' ', '').replaceAll('\n', '')) 186 | } 187 | }) 188 | 189 | test('two levels layout', async t => { 190 | const app = fastify() 191 | await app.register(fastifyHtml) 192 | 193 | app.addLayout(function (inner) { 194 | return app.html` 195 | 196 | 197 | 198 | !${inner} 199 | 200 | 201 | ` 202 | }) 203 | 204 | app.addLayout(function (inner) { 205 | return app.html` 206 |
207 | !${inner} 208 |
209 | ` 210 | }) 211 | 212 | app.get('/', async (req, reply) => { 213 | const name = req.query.name || 'World' 214 | strictEqual(reply.html`

Hello ${name}

`, reply) 215 | return reply 216 | }) 217 | 218 | { 219 | const res = await app.inject({ 220 | method: 'GET', 221 | url: '/' 222 | }) 223 | strictEqual(res.statusCode, 200) 224 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 225 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 226 | 227 | 228 |
229 |

Hello World

230 |
231 | 232 | `.replaceAll(' ', '').replaceAll('\n', '')) 233 | } 234 | 235 | { 236 | const res = await app.inject({ 237 | method: 'GET', 238 | url: '/?name=Matteo' 239 | }) 240 | strictEqual(res.statusCode, 200) 241 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 242 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 243 | 244 | 245 |
246 |

Hello Matteo

247 |
248 | 249 | `.replaceAll(' ', '').replaceAll('\n', '')) 250 | } 251 | }) 252 | 253 | test('two levels layout (async)', async t => { 254 | const app = fastify() 255 | await app.register(fastifyHtml, { async: true }) 256 | 257 | app.addLayout(function (inner) { 258 | return app.html` 259 | 260 | 261 | 262 | !${inner} 263 | 264 | 265 | ` 266 | }) 267 | 268 | app.addLayout(function (inner) { 269 | return app.html` 270 |
271 | !${inner} 272 |
273 | ` 274 | }) 275 | 276 | app.get('/', async (req, reply) => { 277 | const name = req.query.name || 'World' 278 | strictEqual(reply.html`

Hello ${name}

`, reply) 279 | return reply 280 | }) 281 | 282 | { 283 | const res = await app.inject({ 284 | method: 'GET', 285 | url: '/' 286 | }) 287 | strictEqual(res.statusCode, 200) 288 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 289 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 290 | 291 | 292 |
293 |

Hello World

294 |
295 | 296 | `.replaceAll(' ', '').replaceAll('\n', '')) 297 | } 298 | 299 | { 300 | const res = await app.inject({ 301 | method: 'GET', 302 | url: '/?name=Matteo' 303 | }) 304 | strictEqual(res.statusCode, 200) 305 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 306 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 307 | 308 | 309 |
310 |

Hello Matteo

311 |
312 | 313 | `.replaceAll(' ', '').replaceAll('\n', '')) 314 | } 315 | }) 316 | 317 | test('two levels layout with plugins', async t => { 318 | const app = fastify() 319 | await app.register(fastifyHtml) 320 | 321 | app.addLayout(function (inner) { 322 | return app.html` 323 | 324 | 325 | 326 | !${inner} 327 | 328 | 329 | ` 330 | }) 331 | 332 | app.get('/', async (req, reply) => { 333 | const name = req.query.name || 'World' 334 | strictEqual(reply.html`

Hello ${name}

`, reply) 335 | return reply 336 | }) 337 | 338 | app.register(async function (app) { 339 | app.addLayout(function (inner) { 340 | return app.html` 341 |
342 | !${inner} 343 |
344 | ` 345 | }) 346 | 347 | app.get('/inner', async (req, reply) => { 348 | const name = req.query.name || 'World' 349 | strictEqual(reply.html`

Hello ${name}

`, reply) 350 | return reply 351 | }) 352 | }) 353 | 354 | { 355 | const res = await app.inject({ 356 | method: 'GET', 357 | url: '/' 358 | }) 359 | strictEqual(res.statusCode, 200) 360 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 361 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 362 | 363 | 364 |

Hello World

365 | 366 | `.replaceAll(' ', '').replaceAll('\n', '')) 367 | } 368 | 369 | { 370 | const res = await app.inject({ 371 | method: 'GET', 372 | url: '/inner?name=Matteo' 373 | }) 374 | strictEqual(res.statusCode, 200) 375 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 376 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 377 | 378 | 379 |
380 |

Hello Matteo

381 |
382 | 383 | `.replaceAll(' ', '').replaceAll('\n', '')) 384 | } 385 | }) 386 | 387 | test('two levels layout with plugins (async)', async t => { 388 | const app = fastify() 389 | await app.register(fastifyHtml, { async: true }) 390 | 391 | app.addLayout(function (inner) { 392 | return app.html` 393 | 394 | 395 | 396 | !${inner} 397 | 398 | 399 | ` 400 | }) 401 | 402 | app.get('/', async (req, reply) => { 403 | const name = req.query.name || 'World' 404 | strictEqual(reply.html`

Hello ${name}

`, reply) 405 | return reply 406 | }) 407 | 408 | app.register(async function (app) { 409 | app.addLayout(function (inner) { 410 | return app.html` 411 |
412 | !${inner} 413 |
414 | ` 415 | }) 416 | 417 | app.get('/inner', async (req, reply) => { 418 | const name = req.query.name || 'World' 419 | strictEqual(reply.html`

Hello ${name}

`, reply) 420 | return reply 421 | }) 422 | }) 423 | 424 | { 425 | const res = await app.inject({ 426 | method: 'GET', 427 | url: '/' 428 | }) 429 | strictEqual(res.statusCode, 200) 430 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 431 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 432 | 433 | 434 |

Hello World

435 | 436 | `.replaceAll(' ', '').replaceAll('\n', '')) 437 | } 438 | 439 | { 440 | const res = await app.inject({ 441 | method: 'GET', 442 | url: '/inner?name=Matteo' 443 | }) 444 | strictEqual(res.statusCode, 200) 445 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 446 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 447 | 448 | 449 |
450 |

Hello Matteo

451 |
452 | 453 | `.replaceAll(' ', '').replaceAll('\n', '')) 454 | } 455 | }) 456 | 457 | test('use reply in the layout', async t => { 458 | const app = fastify() 459 | await app.register(fastifyHtml) 460 | 461 | let _reply 462 | 463 | app.addLayout(function (inner, reply) { 464 | strictEqual(reply, _reply) 465 | return app.html` 466 | 467 | 468 | !${inner} 469 | 470 | ` 471 | }) 472 | 473 | app.get('/', async (req, reply) => { 474 | _reply = reply 475 | strictEqual(reply.html`

Hello World

`, reply) 476 | return reply 477 | }) 478 | 479 | const res = await app.inject({ 480 | method: 'GET', 481 | url: '/' 482 | }) 483 | strictEqual(res.statusCode, 200) 484 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 485 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 486 | 487 | 488 |

Hello World

489 | 490 | `.replaceAll(' ', '').replaceAll('\n', '')) 491 | }) 492 | 493 | test('use reply in the layout (async)', async t => { 494 | const app = fastify() 495 | await app.register(fastifyHtml, { async: true }) 496 | 497 | let _reply 498 | 499 | app.addLayout(function (inner, reply) { 500 | strictEqual(reply, _reply) 501 | return app.html` 502 | 503 | 504 | !${inner} 505 | 506 | ` 507 | }) 508 | 509 | app.get('/', async (req, reply) => { 510 | _reply = reply 511 | strictEqual(reply.html`

Hello World

`, reply) 512 | return reply 513 | }) 514 | 515 | const res = await app.inject({ 516 | method: 'GET', 517 | url: '/' 518 | }) 519 | strictEqual(res.statusCode, 200) 520 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 521 | strictEqual(res.body.replaceAll(' ', '').replaceAll('\n', ''), ` 522 | 523 | 524 |

Hello World

525 | 526 | `.replaceAll(' ', '').replaceAll('\n', '')) 527 | }) 528 | 529 | test('skip layout with hx-request', async t => { 530 | const app = fastify() 531 | await app.register(fastifyHtml) 532 | 533 | app.addLayout(function (inner) { 534 | return app.html` 535 | 536 | 537 | 538 | ${inner} 539 | 540 | 541 | ` 542 | }, { skipOnHeader: 'hx-request' }) 543 | 544 | app.get('/', async (req, reply) => { 545 | const name = req.query.name || 'World' 546 | strictEqual(reply.html`

Hello ${name}

`, reply) 547 | return reply 548 | }) 549 | 550 | const res = await app.inject({ 551 | method: 'GET', 552 | url: '/', 553 | headers: { 554 | 'hx-request': true 555 | } 556 | }) 557 | strictEqual(res.statusCode, 200) 558 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 559 | strictEqual(res.body, '

Hello World

') 560 | }) 561 | 562 | test('skip layout with hx-request (async)', async t => { 563 | const app = fastify() 564 | await app.register(fastifyHtml, { async: true }) 565 | 566 | app.addLayout(function (inner) { 567 | return app.html` 568 | 569 | 570 | 571 | ${inner} 572 | 573 | 574 | ` 575 | }, { skipOnHeader: 'hx-request' }) 576 | 577 | app.get('/', async (req, reply) => { 578 | const name = req.query.name || 'World' 579 | strictEqual(reply.html`

Hello ${name}

`, reply) 580 | return reply 581 | }) 582 | 583 | const res = await app.inject({ 584 | method: 'GET', 585 | url: '/', 586 | headers: { 587 | 'hx-request': true 588 | } 589 | }) 590 | strictEqual(res.statusCode, 200) 591 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 592 | strictEqual(res.body, '

Hello World

') 593 | }) 594 | 595 | test('render arrays', async t => { 596 | const app = fastify() 597 | app.register(fastifyHtml) 598 | 599 | app.get('/', async (req, reply) => { 600 | const name = req.query.name || 'World' 601 | strictEqual(reply.html`

${['Hello', ' ', name]}

`, reply) 602 | return reply 603 | }) 604 | 605 | { 606 | const res = await app.inject({ 607 | method: 'GET', 608 | url: '/' 609 | }) 610 | strictEqual(res.statusCode, 200) 611 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 612 | strictEqual(res.body, '

Hello World

') 613 | } 614 | 615 | { 616 | const res = await app.inject({ 617 | method: 'GET', 618 | url: '/?name=Matteo' 619 | }) 620 | strictEqual(res.statusCode, 200) 621 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 622 | strictEqual(res.body, '

Hello Matteo

') 623 | } 624 | }) 625 | 626 | test('render arrays (async)', async t => { 627 | const app = fastify() 628 | app.register(fastifyHtml, { async: true }) 629 | 630 | app.get('/', async (req, reply) => { 631 | const name = req.query.name || 'World' 632 | strictEqual(reply.html`

${['Hello', ' ', name]}

`, reply) 633 | return reply 634 | }) 635 | 636 | { 637 | const res = await app.inject({ 638 | method: 'GET', 639 | url: '/' 640 | }) 641 | strictEqual(res.statusCode, 200) 642 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 643 | strictEqual(res.body, '

Hello World

') 644 | } 645 | 646 | { 647 | const res = await app.inject({ 648 | method: 'GET', 649 | url: '/?name=Matteo' 650 | }) 651 | strictEqual(res.statusCode, 200) 652 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 653 | strictEqual(res.body, '

Hello Matteo

') 654 | } 655 | }) 656 | 657 | test('renders an empty tag', async t => { 658 | const app = fastify() 659 | app.register(fastifyHtml) 660 | 661 | app.get('/', async (_req, reply) => { 662 | strictEqual(reply.html``, reply) 663 | return reply 664 | }) 665 | 666 | { 667 | const res = await app.inject({ 668 | method: 'GET', 669 | url: '/' 670 | }) 671 | strictEqual(res.statusCode, 200) 672 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 673 | strictEqual(res.body, '') 674 | } 675 | }) 676 | 677 | test('renders an empty tag (async)', async t => { 678 | const app = fastify() 679 | app.register(fastifyHtml, { async: true }) 680 | 681 | app.get('/', async (_req, reply) => { 682 | strictEqual(reply.html``, reply) 683 | return reply 684 | }) 685 | 686 | { 687 | const res = await app.inject({ 688 | method: 'GET', 689 | url: '/' 690 | }) 691 | strictEqual(res.statusCode, 200) 692 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 693 | strictEqual(res.body, '') 694 | } 695 | }) 696 | 697 | test('does not render null', async t => { 698 | const app = fastify() 699 | app.register(fastifyHtml) 700 | 701 | app.get('/', async (_req, reply) => { 702 | strictEqual(reply.html`${null}`, reply) 703 | return reply 704 | }) 705 | 706 | { 707 | const res = await app.inject({ 708 | method: 'GET', 709 | url: '/' 710 | }) 711 | strictEqual(res.statusCode, 200) 712 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 713 | strictEqual(res.body, '') 714 | } 715 | }) 716 | 717 | test('does not render null (async)', async t => { 718 | const app = fastify() 719 | app.register(fastifyHtml, { async: true }) 720 | 721 | app.get('/', async (_req, reply) => { 722 | strictEqual(reply.html`${null}`, reply) 723 | return reply 724 | }) 725 | 726 | { 727 | const res = await app.inject({ 728 | method: 'GET', 729 | url: '/' 730 | }) 731 | strictEqual(res.statusCode, 200) 732 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 733 | strictEqual(res.body, '') 734 | } 735 | }) 736 | 737 | test('renders a number and bigint', async t => { 738 | const app = fastify() 739 | app.register(fastifyHtml) 740 | 741 | app.get('/', async (_req, reply) => { 742 | strictEqual(reply.html`${42}${42n}`, reply) 743 | return reply 744 | }) 745 | 746 | { 747 | const res = await app.inject({ 748 | method: 'GET', 749 | url: '/' 750 | }) 751 | strictEqual(res.statusCode, 200) 752 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 753 | strictEqual(res.body, '4242') 754 | } 755 | }) 756 | 757 | test('renders a number and bigint (async)', async t => { 758 | const app = fastify() 759 | app.register(fastifyHtml, { async: true }) 760 | 761 | app.get('/', async (_req, reply) => { 762 | strictEqual(reply.html`${42}${42n}`, reply) 763 | return reply 764 | }) 765 | 766 | { 767 | const res = await app.inject({ 768 | method: 'GET', 769 | url: '/' 770 | }) 771 | strictEqual(res.statusCode, 200) 772 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 773 | strictEqual(res.body, '4242') 774 | } 775 | }) 776 | 777 | test('escape', async t => { 778 | const app = fastify() 779 | app.register(fastifyHtml) 780 | 781 | app.post('/', async (req, reply) => { 782 | const char = req.body.char 783 | strictEqual(reply.html`

Hello ${char}

`, reply) 784 | return reply 785 | }) 786 | 787 | for (const char of ['<', '>', '"', '\'', '&']) { 788 | const res = await app.inject({ 789 | method: 'POST', 790 | url: '/', 791 | body: { char } 792 | }) 793 | strictEqual(res.statusCode, 200) 794 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 795 | strictEqual(res.body, `

Hello ${escapeDictionary[char]}

`) 796 | } 797 | }) 798 | 799 | test('escape (async)', async t => { 800 | const app = fastify() 801 | app.register(fastifyHtml, { async: true }) 802 | 803 | app.post('/', async (req, reply) => { 804 | const char = req.body.char 805 | strictEqual(reply.html`

Hello ${char}

`, reply) 806 | return reply 807 | }) 808 | 809 | for (const char of ['<', '>', '"', '\'', '&']) { 810 | const res = await app.inject({ 811 | method: 'POST', 812 | url: '/', 813 | body: { char } 814 | }) 815 | strictEqual(res.statusCode, 200) 816 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 817 | strictEqual(res.body, `

Hello ${escapeDictionary[char]}

`) 818 | } 819 | }) 820 | 821 | test('readFile/promise (async)', async t => { 822 | const app = fastify() 823 | app.register(fastifyHtml, { async: true }) 824 | 825 | app.get('/', async (req, reply) => { 826 | strictEqual(reply.html`

Hello !${readFile('README.md', 'utf8')}

`, reply) 827 | return reply 828 | }) 829 | 830 | const res = await app.inject({ 831 | method: 'GET', 832 | url: '/' 833 | }) 834 | strictEqual(res.statusCode, 200) 835 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 836 | strictEqual(res.body, `

Hello ${README}

`) 837 | }) 838 | 839 | test('layout and a list of promises (async)', async t => { 840 | const app = fastify() 841 | await app.register(fastifyHtml, { async: true }) 842 | 843 | app.addLayout(function (inner) { 844 | return app.html` 845 | 846 | 847 | 848 | !${inner} 849 | 850 | 851 | ` 852 | }) 853 | 854 | app.get('/', async (req, reply) => { 855 | strictEqual(reply.html` 856 |

Hello Matteo

857 |
    858 | !${[ 859 | readFile('README.md', 'utf8'), 860 | readFile('README.md', 'utf8') 861 | ].map(p => app.html`
  • !${p}
  • `)} 862 |
863 | `, reply) 864 | return reply 865 | }) 866 | 867 | const res = await app.inject({ 868 | method: 'GET', 869 | url: '/' 870 | }) 871 | 872 | strictEqual(res.statusCode, 200) 873 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 874 | 875 | const actualOutput = res.body.replaceAll(' ', '').replaceAll('\n', '') 876 | const expectedOutput = ` 877 | 878 | 879 | 880 |

Hello Matteo

881 |
    882 |
  • ${README}
  • 883 |
  • ${README}
  • 884 |
885 | 886 | 887 | `.replaceAll(' ', '').replaceAll('\n', '') 888 | 889 | strictEqual(actualOutput, expectedOutput) 890 | }) 891 | 892 | test('layout and streams as expressions (async)', async t => { 893 | const app = fastify() 894 | await app.register(fastifyHtml, { async: true }) 895 | 896 | app.setErrorHandler(function (error, req, reply) { 897 | console.error(error) 898 | }) 899 | 900 | app.addLayout(function (inner) { 901 | return app.html` 902 | 903 | 904 | 905 | !${inner} 906 | 907 | 908 | ` 909 | }) 910 | 911 | app.get('/', async (req, reply) => { 912 | strictEqual(reply.html` 913 |

Hello Matteo

914 |
    915 | !${[ 916 | createReadStream('README.md', 'utf8') 917 | ].map(p => app.html`
  • !${p}
  • `)} 918 |
  • !${createReadStream('README.md', 'utf8')}
  • 919 |
920 | `, reply) 921 | return reply 922 | }) 923 | 924 | const res = await app.inject({ 925 | method: 'GET', 926 | url: '/' 927 | }) 928 | 929 | strictEqual(res.statusCode, 200) 930 | strictEqual(res.headers['content-type'], 'text/html; charset=utf-8') 931 | 932 | const actualOutput = res.body.replaceAll(' ', '').replaceAll('\n', '') 933 | const expectedOutput = ` 934 | 935 | 936 | 937 |

Hello Matteo

938 |
    939 |
  • ${README}
  • 940 |
  • ${README}
  • 941 |
942 | 943 | 944 | `.replaceAll(' ', '').replaceAll('\n', '') 945 | 946 | strictEqual(actualOutput, expectedOutput) 947 | }) 948 | --------------------------------------------------------------------------------