An open API service indexing awesome lists of open source software.

https://github.com/bcomnes/fragtml

Tagged template html literals with fragment support (for htmx)
https://github.com/bcomnes/fragtml

htmx

Last synced: 26 days ago
JSON representation

Tagged template html literals with fragment support (for htmx)

Awesome Lists containing this project

README

          

# fragtml

[![latest version](https://img.shields.io/npm/v/fragtml.svg)](https://www.npmjs.com/package/fragtml)
[![Actions Status](https://github.com/bcomnes/fragtml/workflows/tests/badge.svg)](https://github.com/bcomnes/fragtml/actions)

[![downloads](https://img.shields.io/npm/dm/fragtml.svg)](https://npmtrends.com/fragtml)
![Types in JS](https://img.shields.io/badge/types_in_js-yes-brightgreen)
[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-7fffff?style=flat&labelColor=ff80ff)](https://github.com/neostandard/neostandard)
[![Socket Badge](https://socket.dev/api/badge/npm/package/fragtml)](https://socket.dev/npm/package/fragtml)

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](https://htmx.org/essays/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:

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

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

Workspace

${html.fragment.start('account-root')}

${account.title}


${account.name}

${html.fragment.start('account-primary-action')}

${account.archived ? 'Restore' : 'Archive'}

${html.fragment.end}

${html.fragment.end}

${html.fragment.start('feed-root')}

${feed.title}



${feed.latest.title}

${html.fragment.start('feed-item-menu')}

Open

${html.fragment.end}


${html.fragment.end}

`
}

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.

```ts
function accountPrimaryAction (context: AccountActionContext) {
return html`

${context.account.archived ? 'Restore' : 'Archive'}

`
}

function accountRoot (context: AccountRootContext) {
return html`

${context.account.title}


${context.account.name}


${accountPrimaryAction(context)}

`
}

function feedItemMenu (context: FeedItemMenuContext) {
return html`

Open

`
}

function feedRoot (context: FeedRootContext) {
return html`

${context.feed.title}



${context.feed.latest.title}
${feedItemMenu(context)}


`
}

function composedDashboardTemplate (context: AccountRootContext & FeedRootContext) {
return html`

Workspace


${accountRoot(context)}
${feedRoot(context)}

`
}

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

```sh
npm install fragtml
```

## Basic usage

```js
import html, { render } from 'fragtml'

const name = ''
const result = html`

Hello ${name}

`

render(result)
// '

Hello <Bret>

'
```

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

```js
return render(html`

${title}

`)
```

The returned object also supports direct string coercion:

```js
const result = html`

${'Hello'}

`

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

## Safe interpolation

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

```js
render(html`

${'alert(1)'}

`)
// '

<script>alert(1)</script>

'
```

The following non-printing values are omitted:

- `null`
- `undefined`
- booleans
- `NaN`

```js
render(html`

${null}${false}${Number.NaN}${0}

`)
// '

0

'
```

## Trusted raw HTML

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

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

render(html`

${raw('trusted')}

`)
// '

trusted

'
```

The tag also exposes the same helper as `.raw`:

```js
html.raw === raw

render(html`

${html.raw('trusted')}

`)
// '

trusted

'
```

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:

```js
const button = html`${''}`

render(html`


${button}

`)
// '
\n <Archive>\n
'
```

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:

```js
const items = ['one', 'two'].map((item) => html`

  • ${item}
  • `)

    render(html`


      ${items}

    `)
    // '
      \n
    • one
    • \n
    • two
    • \n
    '
    ```

    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.

    ```js
    render(html`Save`)
    ```

    When `loading` is truthy:

    ```html
    Save
    ```

    When `loading` is falsey:

    ```html
    Save
    ```

    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:

    ```js
    html`Save`
    ```

    Quoted forms are intentionally unsupported in v1:

    ```js
    html`Save`
    html`Save`
    ```

    ## 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:

    ```txt
    #fragment archive-ui
    ...
    #end
    ```

    In `fragtml`, use boundary tokens:

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

    ### Example

    ```js
    import html, { render } from 'fragtml'

    function contactDetailTemplate ({ contact, fragmentId }) {
    return html(fragmentId)`



    ${html.fragment.start('archive-ui')}
    ${contact.archived
    ? html`Unarchive`
    : html`Archive`}
    ${html.fragment.end}

    Contact


    ${contact.email}




    `
    }

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

    Render the whole page:

    ```js
    contactDetail({ contact })
    ```

    Render only the archive button fragment:

    ```js
    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`:

    ```js
    import { frag, render } from 'fragtml'

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

    return html`

    ${contact.name}


    ${contact.email}


    ${html.fragment.start('archive-ui')}
    ${contact.archived ? 'Unarchive' : 'Archive'}
    ${html.fragment.end}


    `
    }

    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:

    ```js
    const h = frag(fragmentId)

    return h/* html */`

    ${contact.name}


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


    `
    ```

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

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

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

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

    return html`

    ${contact.name}


    ${contact.email}


    ${html.fragment.start('archive-ui')}
    ${contact.archived ? 'Unarchive' : 'Archive'}
    ${html.fragment.end}


    `
    }

    export function contactDetail (args: {
    contact: Contact
    } & RenderOptions) {
    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:

    ```ts
    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 }
    // | { fragmentId: 'outer', context: OuterPageContext & Record }
    // | { fragmentId?: undefined, context: FullPageContext & Record }

    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(fragmentId)

    return html`

    ${context.foo}

    ${html.fragment.start('outer')}

    ${context.title}

    ${html.fragment.start('inner')}
    Inner update target

    ${context.text}

    ${html.fragment.end}

    ${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:

    ```ts
    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`
    Inner update target

    ${context.text}

    `
    }

    export function outer (context: OuterContext) {
    return html`

    ${context.title}


    ${inner(context)}

    `
    }

    export function full (context: FullContext) {
    return html`

    ${context.foo}

    ${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` bundles the public and internal types for one fragment-marked template:

    ```ts
    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` builds the public argument union from a map of fragment contexts and the full-page context:

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

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

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

    ```ts
    type PageTemplateArgs = FragmentTemplateArgs

    // 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:

    ```js
    function pageTemplate ({ fragmentId }) {
    return html(fragmentId)`
    ${html.fragment.start('page')}

    ${title}


    ${body}



    ${html.fragment.end}
    `
    }
    ```

    Prefer:

    ```js
    function pageTemplate ({ fragmentId }) {
    return html(fragmentId)`

    ${title}


    ${body}



    `
    }
    ```

    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.

    ```js
    import html, { render } from 'fragtml'

    function pageTemplate ({ fragmentId }) {
    return html(fragmentId)`
    ${html.fragment.start('profile')}

    Profile

    ${html.fragment.start('profile-actions')}
    Edit profile
    ${html.fragment.end}

    ${html.fragment.end}

    ${html.fragment.start('activity')}

    Activity

    ${html.fragment.start('activity-row')}
    Recent activity
    ${html.fragment.end}

    ${html.fragment.end}
    `
    }

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

    Rendering a root fragment includes its nested fragment content:

    ```js
    page({ fragmentId: 'profile' })
    // '\n

    Profile

    \n\n Edit profile\n'

    page({ fragmentId: 'activity' })
    // '\n

    Activity

    \n\n Recent activity\n'
    ```

    Rendering a nested fragment returns only that nested fragment:

    ```js
    page({ fragmentId: 'profile-actions' })
    // 'Edit profile'

    page({ fragmentId: 'activity-row' })
    // 'Recent activity'
    ```

    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.

    ```js
    html`

    ${value}

    `
    ```

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

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

    ### `frag`

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

    ```js
    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:

    ```js
    const h = frag(fragmentId)

    return h/* html */`

    ${value}

    `
    ```

    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.

    ```js
    render(html`

    ${value}

    `)
    ```

    ### `raw(value)` / `html.raw(value)`

    Marks trusted HTML so it is inserted without escaping.

    ```js
    html`

    ${raw('trusted')}

    `
    ```

    ### `HtmlResult`

    Class returned by `html` and `frag` tagged templates.

    ```js
    import html, { HtmlResult } from 'fragtml'

    const result = html`

    Hello

    `

    result instanceof HtmlResult
    ```

    ### `RawHtml`

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

    ```js
    import { RawHtml, raw } from 'fragtml'

    const trusted = raw('trusted')

    trusted instanceof RawHtml
    ```

    ### Type guards

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

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

    ### Boolean attributes

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

    ```js
    html`Save`
    ```

    ### `html.fragment.start(id)`

    Starts a named fragment range.

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

    ### `html.fragment.end`

    Ends the most recently opened fragment range.

    ```js
    ${html.fragment.end}
    ```

    ### Error classes

    ```js
    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`:

    ```ts
    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`:

    ```ts
    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