[READ-ONLY] Mirror of https://github.com/crutchcorn/causeeffect. JSX for Effect, with generator types preserved. causeeffect.website
README.md

@causeeffect/jsx-content-mapper #

A TypeScript 7.1 content mapper for generator components written in .gtsx files. Babel parses TypeScript and JSX; the mapper lowers JSX to ordinary calls so every expression retains its own type. The SDK supplies the server protocol and UTF-8/UTF-16 source mappings.

// src/example.gtsx
import type { JSX } from '@causeeffect/jsx-content-mapper/runtime';

function* Test() {
  yield 123;
  return <p>Hello</p>;
}

export const Result = <Test />;
// JSX.GeneratorElement<
//   number,
//   JSX.GeneratorElement<never, never, never>,
//   unknown
// >

TypeScript infers unknown for the next type of this unannotated generator. The mapper preserves it instead of converting it to never. An explicit Generator<Yield, Return, Next> annotation retains all three parameters exactly, including a never next type when that is intended.

Setup #

Requires Node.js ^22.18.0 || >=24.11.0 and a TypeScript 7.1 nightly supporting content mappers. The setup below pins 7.1.0-dev.20261002.1; the upstream protocol is experimental.

Install this package in the consuming project and use a nightly compiler:

npm install @causeeffect/jsx-content-mapper
npm install --save-dev --save-exact typescript@7.1.0-dev.20261002.1

The consuming tsconfig.json registers the .gtsx extension at the top level:

{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true
  },
  "contentMappers": [
    {
      "package": "@causeeffect/jsx-content-mapper",
      "extensions": [".gtsx"]
    }
  ],
  "include": ["src"]
}
npm exec --package=typescript@7.1.0-dev.20261002.1 -- tsc --noEmit --runExternalCode

For regular checks, add a script to your application's package.json. npm runs the compiler installed by the pinned development dependency:

{
  "scripts": {
    "typecheck": "tsc --noEmit --runExternalCode"
  }
}
npm run typecheck

--runExternalCode lets TypeScript start the mapper's Node process. No jsx compiler option or JSX factory setting is necessary: its virtual file is .ts. Imports between .gtsx files are supported, including ./component.gtsx.

Types and runtime #

JSX such as <Greeting<string> name="Ada" /> lowers to:

createComponent(Greeting<string>({ name: 'Ada' }));

TypeScript checks the actual call, preserving generic arguments, overloaded component signatures, contextual callback types, required props, and the component's return type. Attributes, spreads, and child expressions are evaluated in their original lexical context, including yield and await. JSX text and entities follow Babel's semantics. A single child becomes props.children; two or more become an array. Children override a children attribute or spread, as in conventional JSX. Fragments and intrinsic tags are supported; JSX spread children ({...children}) produce a diagnostic, so use {children} instead.

The default runtime exports:

  • JSX.GeneratorElement<Yield, Return, Next>: a typed description retaining all three generator channels.
  • createComponent(value): wraps a synchronous or asynchronous generator, retaining its parameters. Ordinary return values use GeneratorElement<never, Return, never>.
  • createElement(tag, props) and Fragment: create intrinsic/fragment descriptions with GeneratorElement<never, never, never>.

JSX without attributes or rendered children calls the component with no arguments: <Test /> becomes createComponent(Test()). A component that accepts an object with optional properties should default the parameter, such as function* Greeting(props: { name?: string } = {}). Otherwise a no-argument call is a TypeScript error. Components with attributes or children receive a props object.

Generator components create their iterator at the JSX expression; their bodies remain suspended until a framework advances it. Ordinary components execute at the expression. Component descriptions expose kind: 'component' and value; intrinsic/fragment descriptions expose kind, type, and props. Descriptions and intrinsic prop snapshots are shallowly frozen.

These are building blocks for a renderer. Intrinsic attributes are deliberately permissive, intrinsic/fragment types do not aggregate child generator channels, and the runtime does not execute effects or render DOM nodes. A future framework can supply its own runtime via mapper options:

{
  "contentMappers": [
    {
      "package": "@causeeffect/jsx-content-mapper",
      "extensions": [".gtsx"],
      "options": { "runtimeModule": "my-framework/gtsx-runtime" }
    }
  ]
}

That module must export createElement, createComponent, and Fragment with the same calling convention. It can specialize element types and intrinsic props.

Classic JSX factories #

For an existing UI runtime, opt into the conventional factory(tag, props, ...children) transform. This mode uses a factory in the source's lexical scope and leaves the resulting element type to that factory:

import { createJsx } from 'my-framework/jsx';

function view(model: Model, builder: Builder) {
  const jsx = createJsx(builder);
  return (
    <main>
      <Greeting name="Ada" />
    </main>
  );
  // jsx.createElement('main', null,
  //   jsx.createElement(Greeting, { name: 'Ada' }))
}
{
  "contentMappers": [
    {
      "package": "@causeeffect/jsx-content-mapper",
      "extensions": [".gtsx"],
      "options": {
        "jsxRuntime": "classic",
        "jsxFactory": "jsx.createElement",
        "jsxFragmentFactory": "jsx.Fragment"
      }
    }
  ]
}

Factories can be identifiers or dotted references, following TypeScript's jsxFactory / jsxFragmentFactory and Babel's classic pragma convention. In classic mode the content mapper also reads these two compiler options when the corresponding mapper option is omitted. Defaults are React.createElement and React.Fragment. No runtime import is inserted; runtimeModule belongs to the generator mode and cannot be combined with classic mode.

Elements without attributes receive null props. Rendered children become variadic arguments, and fragments use the configured fragment value. A children attribute remains in the props object, allowing the runtime to apply its own precedence rules. Explicit component type arguments are retained as TypeScript instantiation expressions, such as Greeting<string>. Type checking uses the factory's call signature, including its overloads and return type.

Effect v4 #

Install Effect in your application to use its yielded operations. The mapper keeps the actual yielded Effect, including its error and service types, available for a future Effect renderer:

npm install effect@4.0.0
import { Effect } from 'effect';

interface Failure {
  readonly reason: string;
}
interface Database {
  readonly query: string;
}
declare const query: Effect.Effect<string, Failure, Database>;

function* View() {
  const message = yield* query;
  return <p>{message}</p>;
}

const view = <View />;
// JSX.GeneratorElement<
//   Effect.Effect<string, Failure, Database>,
//   JSX.GeneratorElement<never, never, never>,
//   any
// >

const program = Effect.gen(View);
// Effect.Effect<JSX.GeneratorElement<never, never, never>, Failure, Database>

The any next type comes from Effect v4's iterator protocol and is preserved too. This pattern supports Effect 4.0.0. Effect types may require "lib": ["es2022", "esnext.disposable"] in the consuming configuration.

JavaScript builds and declarations #

TypeScript's current content mapper implementation does not emit JavaScript for mapped files. It can check them and emit declarations, named example.d.gtsx.ts, plus declaration maps pointing to the original .gtsx file.

The package includes a Vite 8 plugin that uses the same transform and options, then erases TypeScript with Vite's Oxc transformer:

npm install --save-dev vite@^8.1.4
// vite.config.ts
import { defineConfig } from 'vite';
import { gtsx } from '@causeeffect/jsx-content-mapper/vite';

export default defineConfig({
  plugins: [gtsx()], // Default generator mode; or pass classic options above.
});

Import .gtsx files from your application entry point:

// src/main.ts
import { Result } from './example.gtsx';

console.log(Result);

For a new Vite application, add an index.html at the project root that loads that entry point:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>GTSX example</title>
  </head>
  <body>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Start Vite or build the application:

npm exec -- vite
npm exec -- vite build

Use identical JSX options in the plugin and contentMappers configuration. The plugin supports development, production builds, dependency scanning, extensionless .gtsx imports, and source maps to the original files. Vite's ?raw and ?url imports retain their usual behavior. See examples/foldkit for a classic factory that binds to each view's typed builder; the mapper itself has no Foldkit dependency.

Use the public transform in a build-tool integration, then transpile the returned TypeScript with TypeScript, Babel, SWC, or another TypeScript-aware tool:

import { transformGtsx } from '@causeeffect/jsx-content-mapper';

const output = transformGtsx(source, { fileName: 'example.gtsx' });
// output.text: ordinary TypeScript, with an imported runtime and no JSX
// output.extension: '.ts'
// output.mappings: compact content-mapper protocol spans, not a Source Map v3
// output.diagnostics: parser/unsupported-syntax diagnostics when present

Check diagnostics before transpiling, and resolve .gtsx imports in the build-tool integration.