Gracile — JSX Forge (Vite plugin)

A JSX Forge convenience wrapper for Vite projects.
Compiles JSX/TSX to html tagged template literals at build time, using TypeScript & ts-patch.

Tip

You can use this plugin with any Vite+Lit setup!
It’s totally decoupled from the Gracile framework.

Installation

npm i @gracile-labs/vite-plugin-jsx-forge jsx-forge

Setup

Vite config

📄 /vite.config.ts

import { gracileJsxToLiterals } from '@gracile-labs/vite-plugin-jsx-forge/to-literals';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    gracileJsxToLiterals(),

    // ...
  ],
});

TypeScript config

ts-patch must be installed and prepared for the compiler plugin to work.

📄 /tsconfig.json

{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "jsx-forge",

    "plugins": [
      {
        "transform": "jsx-forge/transform",
      },
    ],

    // Should be aligned with Rollup output directory.
    "outDir": "dist",
  },
}

Usage

JSX is compiled statically to Lit html tagged templates. This is not a JSX runtime — think Solid-style compilation, but targeting Lit (or any compatible tagged template library).

Basic example

📄 /src/my-template.tsx

const name = 'World';
const el = <span class="greeting">{name}</span>;

Compiles to:

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

Custom element rendering

📄 /src/features/my-element.el.tsx

'use html-signal';

import { customElement } from 'lit/decorators/custom-element.js';
import { LitElement } from 'lit';

@customElement('my-element')
export class MyElement extends LitElement {
  override render() {
    return (
      <>
        <h1>Hello</h1>
        <button on:click={() => console.log('clicked!')}>Click me</button>
      </>
    );
  }
}

Server document

📄 /src/document.tsx

'use html-server';

export const document = (props: { url: URL; title?: string }) => (
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>{props.title ?? 'My App'}</title>
    </head>
    <body>
      <route-template-outlet></route-template-outlet>
    </body>
  </html>
);

Template flavor directives

Place a "use html-*" directive at the top of a file to control which html tag function is imported:

DirectiveImport sourceUse case
(default)litStandard client templates
'use html-server'@lit-labs/ssrServer-side rendering
'use html-signal'@lit-labs/signalsSignal-aware templates

Options

gracileJsxToLiterals({
  /** Use a full TS LanguageService for type-aware transforms. Default: true */
  typeAware: true,

  /** Path to tsconfig (resolved from project root). Default: 'tsconfig.json' */
  tsconfig: 'tsconfig.json',
});

typeAware (default: true)

When enabled, the plugin maintains a TypeScript LanguageService that provides a type checker. This enables:

When set to false, the transform is purely syntactic (no type checker). This is significantly faster (~20× on incremental saves) but requires you to use explicit namespace prefixes for special bindings:

PrefixLit bindingExample
bool:?attr<input bool:checked={v}/>
if:ifDefined()<a if:href={maybeUrl}>
on:@event<button on:click={fn}>
.prop:.property<el .prop:items={list}>

Spread attributes are skipped in syntactic mode (a console warning is emitted).

Benchmarks

Scenariomedianmeanp95minmax
Cold start .tsx (type-aware)1186.6 ms
Cold start .tsx (syntactic)34.9 ms
Cold start .ts11.3 ms
Warm request .tsx (type-aware)2.0 ms2.1 ms3.3 ms1.4 ms3.3 ms
Warm request .tsx (syntactic)1.7 ms1.7 ms2.8 ms1.2 ms2.8 ms
Warm request .ts (baseline)2.9 ms3.4 ms12.0 ms2.2 ms12.0 ms
Incremental .tsx (type-aware)95.9 ms103.3 ms153.4 ms89.1 ms153.4 ms
Incremental .tsx (syntactic)5.8 ms6.1 ms8.7 ms5.0 ms8.7 ms
Incremental .ts (baseline)4.5 ms4.7 ms5.7 ms4.0 ms5.7 ms

See the full JSX Forge documentation for the complete syntax reference (bindings, components, control helpers, etc.).