JSX Forge (TS transformer)

A JSX to html template literals TypeScript compiler transformer.

Status: Experimental β€” successor to @gracile-labs/babel-plugin-jsx-to-literals.
Built as a native TS compiler plugin (via ts-patch), replacing the previous Babel-based approach.

Specs β€” JSX β†’ HTML tagged template compiler

Note

This is not a JSX runtime. It compiles JSX statically to html tagged templates at build time.
Think Solid-style compilation, but targeting Lit, Β΅html, or any compatible tagged template runtime.

All examples below show real transformer output (auto-generated imports included).


JSX Syntax

Basic elements

// Input
const el = <div>Hello</div>;
// Output
import { html } from 'lit';
const el = html`<div>Hello</div>`;

Fragments

const el = <>Hello</>;
import { html } from 'lit';
const el = html`Hello`;

Nested elements

const el = (
  <div>
    <main>
      <span>Hi</span>
    </main>
  </div>
);
import { html } from 'lit';
const el = html`<div>
  <main><span>Hi</span></main>
</div>`;

Void elements

XML-style self-closing tags are normalized to HTML-compliant void elements.

const el = (
  <>
    <br />
    <hr />
    <img src="#" />
  </>
);
import { html } from 'lit';
const el = html`<br />
  <hr />
  <img src="#" />`;

Non-void custom elements are properly closed:

const el = <my-element></my-element>;
import { html } from 'lit';
const el = html`<my-element></my-element>`;

Expression children

const name = 'World';
const el = <span>{name}</span>;
import { html } from 'lit';
const name = 'World';
const el = html`<span>${name}</span>`;

HTML comments

JSX empty expressions with HTML comment syntax are preserved as real HTML comments.

const el = <div>{/* <!-- My Comment --> */}</div>;
import { html } from 'lit';
const el = html`<div><!-- My Comment --></div>`;

Regular JSX comments ({/* ... */}) without the <!-- --> markers are stripped.


Attributes & Bindings

Static attributes

const el = (
  <div title="Hello" hidden>
    Hi
  </div>
);
import { html } from 'lit';
const el = html`<div title="Hello" hidden>Hi</div>`;

Expression attributes

const el = <div title={'Hello'}>Hi</div>;
import { html } from 'lit';
const el = html`<div title=${'Hello'}>Hi</div>`;

Property binding (_:)

Maps to Lit’s .property syntax.

const el = <div _:className={'abc'}>Hi</div>;
import { html } from 'lit';
const el = html`<div .className=${'abc'}>Hi</div>`;

Event binding (on:)

Maps to Lit’s @event syntax.

const el = <div on:click={handler}>Hi</div>;
import { html } from 'lit';
const el = html`<div @click=${handler}>Hi</div>`;

Boolean binding (bool:)

Maps to Lit’s ?attr syntax, wrapping the value with Boolean().

const el = <div bool:disabled={true}>Hi</div>;
import { html } from 'lit';
const el = html`<div ?disabled=${Boolean(true)}>Hi</div>`;

Attribute serialization (attr:)

Wraps the value with JSON.stringify() for SSR-friendly serialization.

const el = <div attr:data={{ a: 1 }}>Hi</div>;
import { html } from 'lit';
const el = html`<div data=${JSON.stringify({ a: 1 })}>Hi</div>`;

Conditional attribute (if:)

Wraps the value with ifDefined() β€” the attribute is omitted from the DOM when the value is undefined.

const el = <div if:href={url}>Hi</div>;
import { html } from 'lit';
import { ifDefined as $_ifDefined } from 'lit/directives/if-defined.js';
const el = html`<div href=${$_ifDefined(url)}>Hi</div>`;

Ref directive (use:ref)

Injects a ref() directive call in-place (as an attribute-less binding).

const el = <main use:ref={myRef}>Hi</main>;
import { html } from 'lit';
import { ref as $_ref } from 'lit/directives/ref.js';
const el = html`<main ${/* use:ref */ $_ref(myRef)}>Hi</main>`;

Style map (style:map)

Wraps the value with styleMap() and renames to style.

const el = <div style:map={{ color: 'red' }}>Hi</div>;
import { html } from 'lit';
import { styleMap as $_styleMap } from 'lit/directives/style-map.js';
const el = html`<div style=${/* map */ $_styleMap({ color: 'red' })}>Hi</div>`;

Class map (class:map)

Wraps the value with classMap() and renames to class.

const el = <div class:map={{ active: true }}>Hi</div>;
import { html } from 'lit';
import { classMap as $_classMap } from 'lit/directives/class-map.js';
const el = html`<div class=${/* map */ $_classMap({ active: true })}>Hi</div>`;

Class list (class:list)

Wraps the value with clsx() and renames to class.

const el = <div class:list={{ active: true }}>Hi</div>;
import { html } from 'lit';
import { clsx as $_clsx } from 'clsx';
const el = html`<div class=${/* list */ $_clsx({ active: true })}>Hi</div>`;

Unsafe HTML ($:html)

Injects unsafeHTML() in the element body. The attribute value becomes the argument.

const el = <div $:html={'<b>Bold</b>'}>Fallback</div>;
import { html } from 'lit';
import { unsafeHTML as $_unsafeHTML } from 'lit/directives/unsafe-html.js';
const el = html`<div>${$_unsafeHTML('<b>Bold</b>')}</div>`;

Spread attributes

Spread attributes are resolved at compile time using the type checker. Individual properties are expanded into their corresponding bindings.

const props = { id: 'main' };
const el = <div {...props}>Hi</div>;
import { html } from 'lit';
const props = { id: 'main' };
const el = html`<div id=${props['id']}>Hi</div>`;

Component Model

PascalCase JSX tags are compiled to function calls with a props object. Children are passed via the "$:children" key as a nested tagged template.

Function components

const MyComp = ({ children }) => <>{children}</>;
const el = <MyComp>Hello</MyComp>;
import { html } from 'lit';
const MyComp = ({ children }) => html`${children}`;
const el = html`${MyComp({
  '$:children': html`Hello`,
})}`;

Components with props

const MyComp = ({ title, children }) => <div title={title}>{children}</div>;
const el = <MyComp title={'yo'}>Hi</MyComp>;
import { html } from 'lit';
const MyComp = ({ title, children }) =>
  html`<div title=${title}>${children}</div>`;
const el = html`${MyComp({
  '$:children': html`Hi`,
})}`;

Dotted component access

const ns = { Comp: ({ children }) => <main>{children}</main> };
const el = <ns.Comp>Hi</ns.Comp>;
import { html } from 'lit';
const ns = { Comp: ({ children }) => html`<main>${children}</main>` };
const el = html`${ns.Comp({
  '$:children': html`Hi`,
})}`;

Control Helpers

For-each (for:each / repeat)

The <for:each> element inside a .map() call is compiled to Lit’s repeat() directive. The key attribute provides the identity function; children become the template function.

const el = (
  <ul>
    {['a', 'b'].map((id) => (
      <for:each key={id}>
        <li>{id}</li>
      </for:each>
    ))}
  </ul>
);
import { html } from 'lit';
import { repeat as $_repeat } from 'lit/directives/repeat.js';
const el = html`<ul>
  ${$_repeat(
    ['a', 'b'],
    (id) => id,
    (id) => html`<li>${id}</li>`,
  )}
</ul>`;

Literal Flavor Directives

A "use html-*" directive at the top of a file controls which html tag function is imported.

Default (Lit)

const el = <div>Hello</div>;
import { html } from 'lit';
const el = html`<div>Hello</div>`;

Server-side rendering

'use html-server';
const el = <div>Hello</div>;
import { html } from '@lit-labs/ssr';
/** @use html-server */ const el = html`<div>Hello</div>`;

Signals

'use html-signal';
const el = <div>Hello</div>;
import { html } from '@lit-labs/signals';
/** @use html-signal */ const el = html`<div>Hello</div>`;

Auto-Imports

All runtime imports are injected automatically based on usage β€” you never import directives manually. The $_ prefix (configurable via antiCollisionImportPrefix in the preset) prevents naming collisions with user code.

Feature usedAuto-imported
Any JSXhtml from lit (or flavor variant)
if:ifDefined from lit/directives/if-defined.js
use:refref from lit/directives/ref.js
style:mapstyleMap from lit/directives/style-map.js
class:mapclassMap from lit/directives/class-map.js
class:listclsx from clsx
$:htmlunsafeHTML from lit/directives/unsafe-html.js
$:svgunsafeSVG from lit/directives/unsafe-svg.js
<for:each>repeat from lit/directives/repeat.js

Mix & Match

JSX and tagged template literals are fully interoperable. You can embed html literals inside JSX and JSX inside html literals.

// Tagged template inside JSX
const el = <main>{html`<span>Inner</span>`}</main>;
import { html } from 'lit';
const el = html`<main>${html`<span>Inner</span>`}</main>`;

License

ISC β€” Julian Cataldo