@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 useGeneratorElement<never, Return, never>.createElement(tag, props)andFragment: create intrinsic/fragment descriptions withGeneratorElement<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.