Fastify rendering decorators for fragtml.
It provides Fastify rendering ergonomics for function-based fragtml templates:
reply.render()renders HTML and returns a promise for the rendered string.reply.localsanddefaultContextare merged into template data.- custom decorator names, layouts, content types, charset, and minifier hooks are supported.
- it intentionally does not decorate
reply.view,reply.viewAsync, orfastify.view, so it can coexist with@fastify/view.
npm install fastify-fragtml
import Fastify from 'fastify'
import html from 'fragtml'
import fastifyFragtml from 'fastify-fragtml'
const fastify = Fastify()
await fastify.register(fastifyFragtml, {
defaultContext: {
siteName: 'Example'
}
})
fastify.get('/', async (request, reply) => {
const body = await reply.render(context => html`
<h1>${context.title}</h1>
<p>${context.siteName}</p>
`, {
title: 'Home'
})
return reply.send(body)
})Renders the template and returns the HTML string without sending it. It sets Content-Type to text/html; charset=utf-8 unless already set. Rendering errors reject the promise, so return await reply.render(...) or return reply.render(...) stays in Fastify's normal error handling flow.
const body = await reply.render(context => html`
<p>${context.message}</p>
`, {
message: 'Hello'
})
reply.send(body)Template context is merged in this order:
defaultContextreply.locals- render
data
Later values override earlier values.
fastify.addHook('preHandler', async (request, reply) => {
reply.locals = {
requestId: request.id
}
})Layouts are callbacks or objects with a render callback. The render callback receives the already-rendered body value, merged context, and render options. This keeps layouts fragtml-native and lets them own fragment boundaries.
import { frag } from 'fragtml'
await fastify.register(fastifyFragtml, {
layout: (body, context, options) => {
const html = frag(options.fragmentId)
return html`
<!DOCTYPE html>
<html>
<head><title>${context.title}</title></head>
<body>
<main id="main">
${html.fragment.start('main')}
${body}
${html.fragment.end}
</main>
</body>
</html>
`
}
})Render only the layout's main fragment:
reply.render(pageTemplate, data, { fragmentId: 'main' })Disable a global layout for one render:
reply.render(pageTemplate, data, { layout: false })Register named layouts when routes need to choose from a shared set:
import html from 'fragtml'
await fastify.register(fastifyFragtml, {
layout: 'main',
layouts: {
main: {
contentType: 'text/html; charset=utf-8',
render: (body, context) => html`
<!DOCTYPE html>
<html>
<head><title>${context.title}</title></head>
<body>${body}</body>
</html>
`
},
admin: body => html`
<main data-layout="admin">${body}</main>
`
}
})
reply.render(pageTemplate, data, { layout: 'admin' })layout can be a callback, a layout object, a registered layout name, false in render options to disable the default, or null when registering to skip a default layout.
Content type is set only when the reply does not already have a Content-Type header. The precedence is:
- existing reply
Content-Type - render
contentType - resolved layout
contentType - plugin
contentType text/html; charset=utf-8
const body = await reply.render(pageTemplate, data, {
contentType: 'text/vnd.turbo-stream.html; charset=utf-8'
})
reply.send(body)That makes XML and RSS layouts straightforward:
import html from 'fragtml'
const feedTemplate = context => html`
<channel>
<title>${context.title}</title>
<link>${context.siteUrl}</link>
${context.posts.map(post => html`
<item>
<title>${post.title}</title>
<link>${context.siteUrl}${post.href}</link>
<guid>${context.siteUrl}${post.href}</guid>
</item>
`)}
</channel>
`
await fastify.register(fastifyFragtml, {
layouts: {
rss: {
contentType: 'application/rss+xml; charset=utf-8',
render: body => html`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
${body}
</rss>
`
}
}
})
fastify.get('/feed.xml', async (request, reply) => {
const body = await reply.render(feedTemplate, {
title: 'Example Feed',
siteUrl: 'https://example.com',
posts: [
{
title: 'Hello & welcome',
href: '/posts/hello'
}
]
}, {
layout: 'rss'
})
return reply.send(body)
})For stricter TypeScript checks, use the helper functions to infer layout names from the layout map:
import html from 'fragtml'
import fastifyFragtml, {
defineFastifyFragtmlOptions,
defineFragtmlLayouts
} from 'fastify-fragtml'
import type { FragtmlLayoutName, FragtmlTemplate } from 'fastify-fragtml'
interface PageContext {
title: string
}
type PageFragment = 'main'
const layouts = defineFragtmlLayouts<PageContext, PageFragment>()({
main: (body, context) => html`<body><h1>${context.title}</h1>${body}</body>`,
admin: body => html`<main data-layout="admin">${body}</main>`
})
type PageLayout = FragtmlLayoutName<typeof layouts>
const pageTemplate: FragtmlTemplate<PageContext, PageLayout, PageFragment> = (
context,
options
) => {
const h = html<PageFragment>(options.fragmentId)
return h/* html */`
${h.fragment.start('main')}
<p>${context.title}</p>
${h.fragment.end}
`
}
await fastify.register(fastifyFragtml, defineFastifyFragtmlOptions<
PageContext,
typeof layouts,
PageFragment
>({
layout: 'main',
layouts
}))
reply.render(pageTemplate, data, { layout: 'admin' })
// @ts-expect-error layout names are inferred from `layouts`.
reply.render(pageTemplate, data, { layout: 'missing' })
// @ts-expect-error fragment IDs use the `PageFragment` union.
reply.render(pageTemplate, data, { fragmentId: 'missing' })interface FastifyFragtmlOptions {
charset?: string
contentType?: string | false
defaultContext?: object
fragtml?: FragtmlRuntime
layout?: FragtmlLayout | string | null
layouts?: Record<string, FragtmlLayout>
minify?: (html: string, options?: unknown) => string | Promise<string>
minifyOptions?: unknown
options?: {
useHtmlMinifier?: { minify: Function } | Function
htmlMinifierOptions?: unknown
pathsToExcludeHtmlMinifier?: string[]
}
pathsToExcludeMinify?: string[]
propertyName?: string
}
interface FragtmlRuntime {
render: (value: unknown) => string | Promise<string>
raw?: (value: unknown) => RawHtml
html?: HtmlTag
frag?: HtmlTag
default?: HtmlTag
}
interface FragtmlLayoutObject {
contentType?: string | false
render: FragtmlLayout
}propertyName defaults to render. fastify-fragtml deliberately avoids the view, viewAsync, and fastify.view decorator names used by @fastify/view.
By default, rendered values are finalized with fragtml.render(). Pass fragtml when your app uses a custom fragtml instance or a wrapped renderer:
import html, { raw, render } from 'fragtml'
await fastify.register(fastifyFragtml, {
fragtml: {
html,
raw,
render: value => render(value)
}
})Only render(value) is required by the plugin. The optional html, frag, default, and raw fields make module-like custom instances type cleanly.
Fastify rejects duplicate decorators in the same encapsulation scope. If @fastify/view is registered in the same scope, use custom names:
await fastify.register(fastifyFragtml, {
propertyName: 'renderHtml'
})
fastify.get('/', async (request, reply) => {
const body = await reply.renderHtml(template, data)
return reply.send(body)
})The package augments Fastify's default types when imported:
import Fastify from 'fastify'
import html from 'fragtml'
import fastifyFragtml from 'fastify-fragtml'
import type { FragtmlTemplate } from 'fastify-fragtml'
interface PageContext {
title: string
}
const page: FragtmlTemplate<PageContext> = context => html`
<h1>${context.title}</h1>
`
const fastify = Fastify()
await fastify.register(fastifyFragtml)
fastify.get('/', (request, reply) => {
return reply.render(page, { title: 'Home' })
})For custom decorator names, use the exported helper types:
import type { FastifyReply } from 'fastify'
import type { FragtmlReplyDecorators } from 'fastify-fragtml'
type FragtmlReply = FastifyReply & FragtmlReplyDecorators<'renderHtml'>fastify-fragtml re-exports the public fragtml/types.js helpers, including FragmentTemplateTypes, FragmentArgs, FragmentIdOf, FragmentTemplateArgs, HtmlRenderable, HtmlTag, HtmlResult, RawHtml, and RenderOptions.
You can use the same FragmentTemplateTypes pattern from fragtml with reply.render():
import { frag } from 'fragtml'
import fastifyFragtml from 'fastify-fragtml'
import type {
FragmentTemplateTypes,
FragtmlArgsTemplate
} from 'fastify-fragtml'
type InnerPageContext = {
text: string
}
type OuterPageContext = InnerPageContext & {
title: string
}
type FullPageContext = OuterPageContext & {
foo: string
}
type PageTemplate = FragmentTemplateTypes<{
fragments: {
inner: InnerPageContext
outer: OuterPageContext
}
full: FullPageContext
}>
type PageArgs = PageTemplate['args']
type PageTemplateArgs = PageTemplate['templateArgs']
type PageFragment = PageTemplate['fragmentId']
const pageTemplate: FragtmlArgsTemplate<PageArgs> = ({
context,
fragmentId
}: PageTemplateArgs) => {
const html = frag<PageFragment>(fragmentId)
return html`
<div>${context.foo}</div>
${html.fragment.start('outer')}
<section>
<h2>${context.title}</h2>
${html.fragment.start('inner')}
<button>Inner update target</button>
<div>${context.text}</div>
${html.fragment.end}
</section>
${html.fragment.end}
`
}
await fastify.register(fastifyFragtml)
fastify.get('/inner', (request, reply) => {
return reply.render(pageTemplate, {
fragmentId: 'inner',
context: {
text: 'Updated body text'
}
})
})
fastify.get('/full', (request, reply) => {
return reply.render(pageTemplate, {
context: {
foo: 'Full page field',
title: 'Outer fragment title',
text: 'Updated body text'
}
})
})FragtmlTemplate and FragtmlRenderOptions accept a fragment ID union as their third generic parameter. That lets TypeScript catch typos in opts.fragmentId:
import { frag } from 'fragtml'
import type { FragtmlRenderOptions, FragtmlTemplate } from 'fastify-fragtml'
type PageContext = { title: string }
type PageFragment = 'main'
const page: FragtmlTemplate<PageContext, string, PageFragment> = (context, options) => {
const html = frag<PageFragment>(options.fragmentId)
return html`
${html.fragment.start('main')}
<h1>${context.title}</h1>
${html.fragment.end}
`
}
const options: FragtmlRenderOptions<PageContext, string, PageFragment> = {
fragmentId: 'main'
}
reply.render(page, { title: 'Home' }, options)
// @ts-expect-error "missing" is not a known page fragment.
reply.render(page, { title: 'Home' }, { fragmentId: 'missing' })MIT