Skip to content

bcomnes/fragtml

Repository files navigation

fragtml

latest version Actions Status

downloads Types in JS neostandard javascript style Socket Badge

A safe-by-default, string-generating HTML tagged template library with inline fragment support for server-rendered hypermedia apps.

fragtml is inspired by common-tags HTML formatting behavior and by htmx-style template fragments. It lets you keep a full template and its partial update fragments together in one JavaScript template function.

The practical benefits of inline fragments are still being assessed against function-based composition. Fragments can reduce indirection in large templates by keeping related partial update targets in place, but they also increase type complexity compared with function-based partials.

Why fragments?

Fragments are useful when a page has several partial update targets, but those targets are easiest to understand in the structure of the full template:

import html, { render } from 'fragtml'
import type { FragmentTemplateTypes } from 'fragtml/types.js'

function dashboardTemplate ({
  account,
  feed,
  fragmentId
}: DashboardTemplate['templateArgs']) {
  return html<DashboardTemplate['fragmentId']>(fragmentId)/* html */`
    <main>
      <h1>Workspace</h1>

      ${html.fragment.start('account-root')}
      <section id="account">
        <h2>${account.title}</h2>
        <p>${account.name}</p>

        ${html.fragment.start('account-primary-action')}
        <button hx-patch="/accounts/${account.id}">
          ${account.archived ? 'Restore' : 'Archive'}
        </button>
        ${html.fragment.end}
      </section>
      ${html.fragment.end}

      ${html.fragment.start('feed-root')}
      <section id="feed">
        <h2>${feed.title}</h2>
        <article>
          ${feed.latest.title}

          ${html.fragment.start('feed-item-menu')}
          <menu>
            <button hx-get="/feed/${feed.latest.id}/menu">Open</button>
          </menu>
          ${html.fragment.end}
        </article>
      </section>
      ${html.fragment.end}
    </main>
  `
}

export function dashboard (args: DashboardTemplate['args']) {
  return render(dashboardTemplate(args))
}

// Rendering different fragments, with type safety
dashboard({
  account: {
    id: 'acct_123',
    title: 'Account',
    name: 'Acme',
    archived: false
  },
  feed: {
    title: 'Feed',
    latest: {
      id: 'item_123',
      title: 'New signup'
    }
  }
})

dashboard({
  fragmentId: 'account-primary-action',
  account: {
    id: 'acct_123',
    archived: false
  }
})

dashboard({
  fragmentId: 'feed-item-menu',
  feed: {
    latest: {
      id: 'item_123'
    }
  }
})

type AccountActionContext = {
  account: {
    id: string
    archived: boolean
  }
}

type AccountRootContext = AccountActionContext & {
  account: AccountActionContext['account'] & {
    title: string
    name: string
  }
}

type FeedItemMenuContext = {
  feed: {
    latest: {
      id: string
    }
  }
}

type FeedRootContext = FeedItemMenuContext & {
  feed: FeedItemMenuContext['feed'] & {
    title: string
    latest: FeedItemMenuContext['feed']['latest'] & {
      title: string
    }
  }
}

type DashboardTemplate = FragmentTemplateTypes<{
  fragments: {
    'account-root': AccountRootContext
    'account-primary-action': AccountActionContext
    'feed-root': FeedRootContext
    'feed-item-menu': FeedItemMenuContext
  }
  full: AccountRootContext & FeedRootContext
}>

The same template can render the full dashboard, account-root, account-primary-action, feed-root, or feed-item-menu. The public dashboard(args) wrapper enforces the required context fields for each target, while the full-page structure stays visible and each htmx target remains named next to the markup it updates.

For comparison, the same page can be built with function composition. This keeps each partial reusable, but the full-page structure is now spread across several functions. The types are much simpler though and there are less moving parts.

function accountPrimaryAction (context: AccountActionContext) {
  return html`
    <button hx-patch="/accounts/${context.account.id}">
      ${context.account.archived ? 'Restore' : 'Archive'}
    </button>
  `
}

function accountRoot (context: AccountRootContext) {
  return html`
    <section id="account">
      <h2>${context.account.title}</h2>
      <p>${context.account.name}</p>
      ${accountPrimaryAction(context)}
    </section>
  `
}

function feedItemMenu (context: FeedItemMenuContext) {
  return html`
    <menu>
      <button hx-get="/feed/${context.feed.latest.id}/menu">Open</button>
    </menu>
  `
}

function feedRoot (context: FeedRootContext) {
  return html`
    <section id="feed">
      <h2>${context.feed.title}</h2>
      <article>
        ${context.feed.latest.title}
        ${feedItemMenu(context)}
      </article>
    </section>
  `
}

function composedDashboardTemplate (context: AccountRootContext & FeedRootContext) {
  return html`
    <main>
      <h1>Workspace</h1>
      ${accountRoot(context)}
      ${feedRoot(context)}
    </main>
  `
}

export function composedDashboard (context: AccountRootContext & FeedRootContext) {
  return render(composedDashboardTemplate(context))
}

Use fragments when preserving the full template structure is the point. Use composed functions when these pieces need to be reused by other templates.

Install

npm install fragtml

Basic usage

import html, { render } from 'fragtml'

const name = '<Bret>'
const result = html`<p>Hello ${name}</p>`

render(result)
// '<p>Hello &lt;Bret&gt;</p>'

html returns an intermediate result object, not a primitive string. Use render() at route-handler boundaries:

return render(html`<h1>${title}</h1>`)

The returned object also supports direct string coercion:

const result = html`<p>${'Hello'}</p>`

String(result)
result.toString()
`${result}`

XML / RSS

fragtml can render XML-shaped documents too. Static markup is left alone, and interpolated values are escaped.

import html, { render } from 'fragtml'

const siteUrl = 'https://example.com'
const posts = [
  {
    title: 'Hello & welcome',
    href: '/posts/hello'
  }
]

const feed = html`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Example Feed</title>
    <link>${siteUrl}</link>
    ${posts.map(post => html`
      <item>
        <title>${post.title}</title>
        <link>${siteUrl}${post.href}</link>
        <guid>${siteUrl}${post.href}</guid>
      </item>
    `)}
  </channel>
</rss>
`

render(feed)
// '<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0">...'

Set the HTTP Content-Type at the response boundary, such as application/rss+xml; charset=utf-8 for RSS.

Safe interpolation

Static template HTML is left as-is. Ordinary substitutions are escaped:

render(html`<p>${'<script>alert(1)</script>'}</p>`)
// '<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>'

The following non-printing values are omitted:

  • null
  • undefined
  • booleans
  • NaN
render(html`<p>${null}${false}${Number.NaN}${0}</p>`)
// '<p>0</p>'

Trusted raw HTML

Use raw() for trusted HTML that should not be escaped:

import html, { raw, render } from 'fragtml'

render(html`<p>${raw('<strong>trusted</strong>')}</p>`)
// '<p><strong>trusted</strong></p>'

The tag also exposes the same helper as .raw:

html.raw === raw

render(html`<p>${html.raw('<em>trusted</em>')}</p>`)
// '<p><em>trusted</em></p>'

Only pass trusted HTML to raw(). User input should be interpolated normally so it is escaped.

There is no public unsafeHtml tag in v1. Prefer local, explicit trust boundaries with raw().

Composition

Nested html results are treated as trusted fragtml output, while their own substitutions remain escaped:

const button = html`<button>${'<Archive>'}</button>`

render(html`
  <div hx-target="this">
    ${button}
  </div>
`)
// '<div hx-target="this">\n  <button>&lt;Archive&gt;</button>\n</div>'

Nested results render in their own fragment scope. A parent template does not see fragment IDs declared by child templates; pass a fragmentId to the child template when you want the child to render one of its own fragments.

Arrays are inlined with indentation-aware formatting:

const items = ['one', 'two'].map((item) => html`<li>${item}</li>`)

render(html`
  <ul>
    ${items}
  </ul>
`)
// '<ul>\n  <li>one</li>\n  <li>two</li>\n</ul>'

String substitutions containing newlines are split and aligned to the surrounding indentation.

Boolean attributes

Use ?name=${condition} to toggle boolean attributes. When the value is truthy, fragtml renders the bare attribute. When the value is falsey, it omits the attribute.

render(html`<button ?disabled=${loading}>Save</button>`)

When loading is truthy:

<button disabled>Save</button>

When loading is falsey:

<button>Save</button>

This syntax is useful for native HTML boolean attributes such as disabled, checked, selected, readonly, required, multiple, autofocus, hidden, and open.

Only the unquoted form is supported:

html`<button ?disabled=${loading}>Save</button>`

Quoted forms are intentionally unsupported in v1:

html`<button ?disabled="${loading}">Save</button>`
html`<button ?disabled='${loading}'>Save</button>`

Fragments

Fragments mark named ranges inside a larger template. Calling html(fragmentId) on that template renders either the full template or one selected fragment:

  • html() / html(undefined) renders the full template.
  • html('archive-ui') renders only the archive-ui fragment.
  • html({ fragmentId: 'archive-ui' }) is the options-object form.

This lets one view function serve both full-page requests and htmx-style fragment requests by passing the requested fragment ID through to html(fragmentId).

This mirrors the htmx article’s idea:

#fragment archive-ui
  ...
#end

In fragtml, use boundary tokens:

${html.fragment.start('archive-ui')}
...
${html.fragment.end}

Example

import html, { render } from 'fragtml'

function contactDetailTemplate ({ contact, fragmentId }) {
  return html(fragmentId)`
    <html>
      <body>
        <div hx-target="this">
          ${html.fragment.start('archive-ui')}
          ${contact.archived
            ? html`<button hx-patch="/contacts/${contact.id}/unarchive">Unarchive</button>`
            : html`<button hx-delete="/contacts/${contact.id}">Archive</button>`}
          ${html.fragment.end}
        </div>

        <h3>Contact</h3>
        <p>${contact.email}</p>
      </body>
    </html>
  `
}

export function contactDetail (args) {
  return render(contactDetailTemplate(args))
}

Render the whole page:

contactDetail({ contact })

Render only the archive button fragment:

contactDetail({ contact, fragmentId: 'archive-ui' })

Fragment boundary tokens are not included in either output.

If you want a simple local tag name for editor highlighting or repeated use, frag is an alias of html:

import { frag, render } from 'fragtml'

function contactDetailTemplate ({ contact, fragmentId }) {
  const html = frag(fragmentId)

  return html`
    <article>
      <h3>${contact.name}</h3>
      <p>${contact.email}</p>

      <div hx-target="this">
        ${html.fragment.start('archive-ui')}
        <button>${contact.archived ? 'Unarchive' : 'Archive'}</button>
        ${html.fragment.end}
      </div>
    </article>
  `
}

export function contactDetail (args) {
  return render(contactDetailTemplate(args))
}

Calling html('archive-ui') directly before a template can break editor HTML highlighting because many highlighters only recognize a simple tag identifier before the backtick. Assign the fragment tag to a local variable for highlighting, or add a /* html */ marker. Editors such as Sublime Text and Zed understand this marker:

const h = frag(fragmentId)

return h/* html */`
  <article>
    <h3>${contact.name}</h3>

    <div hx-target="this">
      ${h.fragment.start('archive-ui')}
      <button>Archive</button>
      ${h.fragment.end}
    </div>
  </article>
`

In TypeScript, you can use an explicit fragment-name union to type-check both incoming fragment IDs and declared fragment boundaries:

import { frag, render } from 'fragtml'
import type { RenderOptions } from 'fragtml/types.js'

type ContactFragment = 'archive-ui' | 'details'

function contactDetailTemplate ({
  contact,
  fragmentId
}: {
  contact: Contact
} & RenderOptions<ContactFragment>) {
  const html = frag<ContactFragment>(fragmentId)

  return html`
    <article>
      <h3>${contact.name}</h3>
      <p>${contact.email}</p>

      <div hx-target="this">
        ${html.fragment.start('archive-ui')}
        <button>${contact.archived ? 'Unarchive' : 'Archive'}</button>
        ${html.fragment.end}
      </div>
    </article>
  `
}

export function contactDetail (args: {
  contact: Contact
} & RenderOptions<ContactFragment>) {
  return render(contactDetailTemplate(args))
}

Fragment context typing

Fragment markers keep the whole template in one expression. JavaScript evaluates every ${...} substitution before fragtml selects a fragment, so the context type must cover the whole template even when a smaller fragment is requested. If different fragments need different fields, those fields usually have to be optional or otherwise guarded:

import { frag, render } from 'fragtml'
import type {
  FragmentTemplateTypes
} from 'fragtml/types.js'

type InnerPageContext = {
  text: string
}

type OuterPageContext = InnerPageContext & {
  title: string
}

type FullPageContext = OuterPageContext & {
  foo: string
}

type PageTemplate = FragmentTemplateTypes<{
  // Fragment IDs and their required context types.
  fragments: {
    inner: InnerPageContext
    outer: OuterPageContext
  }
  // Context required to render the full template.
  full: FullPageContext
}>

type PageFragment = PageTemplate['fragmentId']
// Resolves to:
// 'inner' | 'outer'

type PageArgs = PageTemplate['args']
// Resolves to:
// | { fragmentId: 'inner', context: InnerPageContext & Record<string, unknown> }
// | { fragmentId: 'outer', context: OuterPageContext & Record<string, unknown> }
// | { fragmentId?: undefined, context: FullPageContext & Record<string, unknown> }

type PageTemplateArgs = PageTemplate['templateArgs']
// Resolves to:
// {
//   fragmentId?: 'inner' | 'outer' | undefined
//   context: {
//     foo?: string
//     title?: string
//     text?: string
//   }
// }

function pageTemplate ({
  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}
  `
}

export function page (args: PageArgs) {
  return render(pageTemplate(args))
}

// These calls are still type-safe: PageArgs enforces the required context
// fields for each fragment target at the public call boundary.
page({
  fragmentId: 'inner',
  context: { text: 'Updated body text' }
})

page({
  fragmentId: 'outer',
  context: {
    // Extra already-loaded data is allowed.
    foo: 'Full page field',
    title: 'Outer fragment title',
    text: 'Updated body text'
  }
})

page({
  context: {
    foo: 'Full page field',
    title: 'Outer fragment title',
    text: 'Updated body text'
  }
})

// @ts-expect-error outer fragments require both title and text.
page({
  fragmentId: 'outer',
  context: { text: 'Missing the outer title' }
})

This pattern keeps one large template while hiding most of the type complexity in FragmentTemplateTypes and the small page(args: PageArgs) wrapper. The wrapper enforces the required fields for each fragment target, while PageTemplateArgs gives the shared template implementation a looser context type because every ${...} expression is still evaluated before fragment selection.

If you want exact input types per render target, split the fragments into typed template functions and compose them without fragment markers. This also makes the smaller template functions reusable across multiple callsites or larger templates:

import html, { render } from 'fragtml'

type InnerContext = {
  text: string
}

type OuterContext = InnerContext & {
  title: string
}

type FullContext = OuterContext & {
  foo: string
}

export function inner (context: InnerContext) {
  return html`
    <button>Inner update target</button>
    <div>${context.text}</div>
  `
}

export function outer (context: OuterContext) {
  return html`
    <section>
      <h2>${context.title}</h2>
      ${inner(context)}
    </section>
  `
}

export function full (context: FullContext) {
  return html`
    <div>${context.foo}</div>
    ${outer(context)}
  `
}

render(inner({ text: 'Updated body text' }))

render(outer({
  title: 'Outer section title',
  text: 'Updated body text'
}))

render(full({
  foo: 'Full page field',
  title: 'Outer section title',
  text: 'Updated body text'
}))

// @ts-expect-error outer requires both title and text.
render(outer({ text: 'Missing the outer title' }))

Use fragments to preserve the structure of a larger template while still rendering named pieces of it. Use function composition when those pieces need to be reused across multiple parent templates, similar to React components or partials in other template languages.

Fragment context helpers

FragmentTemplateTypes<Config> bundles the public and internal types for one fragment-marked template:

type PageTemplate = FragmentTemplateTypes<{
  fragments: {
    inner: InnerPageContext
    outer: OuterPageContext
  }
  full: FullPageContext
}>

type PageArgs = PageTemplate['args']
type PageTemplateArgs = PageTemplate['templateArgs']

fragments maps fragment IDs to their required context types. full is the context required to render the full template.

PageTemplate['args'] is the public call boundary that enforces the required fields for the selected render target. Extra context fields are allowed, so callers can pass already-loaded full-page data to smaller fragment renders. PageTemplate['templateArgs'] is the looser implementation type for the shared template body; its context is an OptionalMerge of every render target context because the full template expression is evaluated before fragment selection.

FragmentArgs<Fragments, FullContext> builds the public argument union from a map of fragment contexts and the full-page context:

type PageArgs = FragmentArgs<{
  inner: InnerPageContext
  outer: OuterPageContext
}, FullPageContext>

// Equivalent to:
// | { fragmentId: 'inner', context: InnerPageContext & Record<string, unknown> }
// | { fragmentId: 'outer', context: OuterPageContext & Record<string, unknown> }
// | { fragmentId?: undefined, context: FullPageContext & Record<string, unknown> }

FragmentTemplateArgs<Args> derives the full argument type for the shared template implementation. FragmentTemplateContext<Args> derives only its looser context field. Both make fields from every render target optional:

type PageTemplateArgs = FragmentTemplateArgs<PageArgs>

// Equivalent to:
// {
//   fragmentId?: 'inner' | 'outer' | undefined
//   context: {
//     foo?: string
//     title?: string
//     text?: string
//   }
// }

Most users should start with FragmentTemplateTypes. Use FragmentArgs and FragmentTemplateArgs directly if you prefer to assemble the public and implementation types yourself. Lower-level utility types live in fragtml/lib/html-types.js for advanced cases, but they are not part of the recommended surface.

Fragment rules

  • Fragment IDs must be unique within a rendered template.
  • Nested template results have their own fragment scope; parent templates do not select or conflict with child fragment IDs.
  • Missing fragments throw FragmentNotFoundError.
  • Duplicate fragment IDs throw DuplicateFragmentError.
  • html.fragment.end without a matching start throws FragmentBoundaryError.
  • An unclosed html.fragment.start(id) throws FragmentBoundaryError.

Fragment antipatterns

Do not wrap the entire template in an outer fragment. Rendering without a fragmentId already renders the whole template, so a fragment that covers everything adds a fake target without changing the output.

Avoid:

function pageTemplate ({ fragmentId }) {
  return html(fragmentId)`
    ${html.fragment.start('page')}
    <main>
      <h1>${title}</h1>
      <p>${body}</p>
    </main>
    ${html.fragment.end}
  `
}

Prefer:

function pageTemplate ({ fragmentId }) {
  return html(fragmentId)`
    <main>
      <h1>${title}</h1>
      <p>${body}</p>
    </main>
  `
}

Only mark fragments that represent real partial update targets inside the full template.

Nested fragments

Nested fragments are supported with stack semantics. This is useful when a larger region can be re-rendered as a whole, but a smaller region inside it is also a valid htmx update target. A single template can contain multiple independent nested fragment groups, each with its own root fragment.

import html, { render } from 'fragtml'

function pageTemplate ({ fragmentId }) {
  return html(fragmentId)`
    ${html.fragment.start('profile')}
    <section>
      <h2>Profile</h2>

      ${html.fragment.start('profile-actions')}
      <button>Edit profile</button>
      ${html.fragment.end}
    </section>
    ${html.fragment.end}

    ${html.fragment.start('activity')}
    <section>
      <h2>Activity</h2>

      ${html.fragment.start('activity-row')}
      <article>Recent activity</article>
      ${html.fragment.end}
    </section>
    ${html.fragment.end}
  `
}

export function page (args) {
  return render(pageTemplate(args))
}

Rendering a root fragment includes its nested fragment content:

page({ fragmentId: 'profile' })
// '<section>\n  <h2>Profile</h2>\n\n  <button>Edit profile</button>\n</section>'

page({ fragmentId: 'activity' })
// '<section>\n  <h2>Activity</h2>\n\n  <article>Recent activity</article>\n</section>'

Rendering a nested fragment returns only that nested fragment:

page({ fragmentId: 'profile-actions' })
// '<button>Edit profile</button>'

page({ fragmentId: 'activity-row' })
// '<article>Recent activity</article>'

Use nested fragments sparingly. Prefer flat fragments unless you actually need both a parent region and a child region as independently renderable update targets.

API

html

Safe-by-default template tag.

html`<p>${value}</p>`

Pass a fragment ID before the tagged template to render a selected fragment from that template:

html('name')`...`
html({ fragmentId: 'name' })`...`

frag

Alias of html, useful when you want a local tag name for editor highlighting or repeated use:

import { frag } from 'fragtml'

const html = frag(fragmentId)

Calling html('name') directly before a template can break editor HTML highlighting because the tag expression is no longer a simple identifier. Assign the result to a local tag name, or use the /* html */ marker that Sublime Text and Zed understand:

const h = frag(fragmentId)

return h/* html */`<p>${value}</p>`

This is kind of hit or miss per editor. Play around and see what works. You can usually figure something out.

render(value)

Converts a fragtml result to a primitive string.

render(html`<p>${value}</p>`)

raw(value) / html.raw(value)

Marks trusted HTML so it is inserted without escaping.

html`<p>${raw('<strong>trusted</strong>')}</p>`

HtmlResult

Class returned by html and frag tagged templates.

import html, { HtmlResult } from 'fragtml'

const result = html`<p>Hello</p>`

result instanceof HtmlResult

RawHtml

Class returned by raw(value) and html.raw(value).

import { RawHtml, raw } from 'fragtml'

const trusted = raw('<strong>trusted</strong>')

trusted instanceof RawHtml

Type guards

Use the public type guards to narrow unknown values without importing from internal lib/ paths:

import {
  isFragmentBoundary,
  isHtmlResult,
  isRawHtml
} from 'fragtml'

Boolean attributes

Use unquoted ?name=${condition} syntax to toggle a boolean attribute.

html`<button ?disabled=${loading}>Save</button>`

html.fragment.start(id)

Starts a named fragment range.

${html.fragment.start('archive-ui')}

html.fragment.end

Ends the most recently opened fragment range.

${html.fragment.end}

Error classes

import {
  DuplicateFragmentError,
  FragmentBoundaryError,
  FragmentNotFoundError
} from 'fragtml'

TypeScript

fragtml is written in typed JavaScript and ships generated declaration files.

Runtime classes such as HtmlResult and RawHtml are exported from the package root. Type-only aliases are exported from fragtml/types.js:

import type {
  FragmentArgs,
  FragmentIdOf,
  FragmentTemplateArgs,
  FragmentTemplateTypes,
  HtmlRenderable,
  HtmlResult,
  HtmlTag,
  RawHtml,
  RenderOptions
} from 'fragtml/types.js'

HtmlResult is both a runtime class from the package root and an importable type from fragtml/types.js:

import { HtmlResult } from 'fragtml'
import type { HtmlResult as HtmlResultType } from 'fragtml/types.js'

function sendHtml (result: HtmlResultType) {
  return result.toString()
}

function isHtmlResultValue (value: unknown): value is HtmlResultType {
  return value instanceof HtmlResult
}

License

MIT

About

Tagged template html literals with fragment support (for htmx)

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors