Babel Plugin (JSX)
@exodra/babel-plugin-jsx compiles Exodra JSX into h() calls with the
typed-bucket architecture and compile-time static hoisting. It is the only
supported way to compile Exodra JSX — TypeScript's native JSX transform and
@babel/plugin-transform-react-jsx emit jsx() / jsxs() runtime calls that
Exodra does not have.
Installation
npm install --save-dev @exodra/babel-plugin-jsx
Configuration
.babelrc
{
"plugins": [
["@exodra/babel-plugin-jsx", { "hoistStatic": true }]
]
}
With Vite
If you use @exodra/vite-plugin, this is wired up for you.
To configure Babel manually:
// vite.config.js
import { defineConfig } from 'vite';
import * as babel from '@babel/core';
export default defineConfig({
esbuild: false,
plugins: [
{
name: 'exodra-jsx',
transform(code, id) {
if (id.endsWith('.jsx') || id.endsWith('.tsx')) {
return babel.transform(code, {
filename: id,
plugins: [['@exodra/babel-plugin-jsx', { hoistStatic: true }]],
});
}
},
},
],
});
Typed-bucket architecture
The plugin maps the singular JSX buckets to the plural buckets of the core
schema (static → static, bindable → bindables,
bindableList → bindableLists, handlers → handlers,
bindableHandlers → bindableHandlers):
// JSX input
<div
static={{ id: 'container', class: 'box' }}
bindable={{ hidden: isHidden }}
handlers={{ onClick: handleClick }}
>
Content
</div>
// Transformed output
h('div', {
static: { id: 'container', class: 'box', children: text('Content') },
bindables: { hidden: isHidden },
handlers: { onClick: handleClick },
});
isHidden is a bindable / derive object passed directly — not a thunk.
Strict mode
The plugin enforces strict separation of concerns: flat React-style
attributes throw a compile error pointing at the right bucket. A flat
onClick would silently land in static (a dead handler), and a flat class
would blur the static/reactive split — so both fail loud.
// ❌ WRONG — flat attributes not allowed
<button onClick={handleClick}>Click</button>
// Error: Exodra JSX: flat event prop "onClick" is not allowed.
// Use handlers={{ onClick: ... }}
<div class="box" />
// Error: Exodra JSX: flat attribute "class" is not allowed.
// Put it in a bucket — static={{ "class": ... }} …
// ✅ CORRECT — explicit buckets
<button
static={{ class: 'box', children: 'Click' }}
handlers={{ onClick: handleClick }}
/>
Event props (on*) belong in handlers (or bindableHandlers for a reactive
handler); lifecycle hooks such as onExoMount go in static. See the
JSX guide for the full set of rules.
Static hoisting
With hoistStatic enabled (the default), static subtrees in loops get an
auto-generated clone-cache key so they can be cloned instead of rebuilt:
// Input
items.map(item => (
<div static={{ class: 'item' }}>
<span static={{ children: item.name }} />
</div>
))
// Output with an auto-generated cacheKey (3rd arg of h())
const _ck1 = Symbol();
items.map(item =>
h('div', {
static: {
class: 'item',
children: h('span', { static: { children: text(item.name) } }),
},
}, _ck1)
)
Two-way binding directives
bind:value / bind:checked compile to a mergeAttrs(...) call (imported from
@exodra/attrs) plus the appropriate @exodra/forms helper
(picked from the element / type at compile time, so only the used variants are
imported):
// Input
<input bind:value={inputValue} />
// Output
import { mergeAttrs } from '@exodra/attrs';
import { bindText } from '@exodra/forms';
h('input', mergeAttrs({}, bindText(inputValue)))
props={} — spread a bucket object
props={obj} spreads a full bucket object (the schema shape:
static / bindables / bindableLists / handlers / bindableHandlers) into
the element. The compiler does the dumbest, most predictable thing — a plain
JS object spread, no implicit merge:
// Input
<button props={base} static={{ class: 'btn' }} handlers={{ onClick: f }} />
// Output — spread first (the base), inline buckets after (they win)
h('button', { ...base, static: { class: 'btn' }, handlers: { onClick: f } })
- Plain spread, one level. An inline bucket replaces that whole
top-level field of the spread object —
staticoverridesbase.staticwholesale, it does not descend into it. That's exactly JS spread semantics, so there is nothing hidden to learn. - The object is the schema shape (plural buckets), so helpers that return it
drop straight in:
props={bindText(name)}(@exodra/forms),props={bindField(patch, 'title')}(@oimdb/exodra). This is the Exodra "prop-getter" pattern. - Pick your own merge strategy — because you build the object yourself. Want
two-level composition? Ask for it explicitly:
props={mergeAttrs(base, getInputProps())}. exo:schemastill wins (it replaces everything); combined withbind:, the spread object becomes themergeAttrsbase.- Watch children + a helper's
static. JSX children fold intostatic.children, creating an inlinestaticbucket — which then replaces a helper'sstaticwholesale. Keepstaticout of a helper used with children, or merge it yourself. (Real helpers usually returnhandlers/bindables, which don't collide.)
An element that carries a props={} spread is never static-hoisted (the spread
is a runtime value).
Options
| Option | Type | Default | Description |
|---|---|---|---|
importSource | string | '@exodra/core' | Module to import the pragma / text / Fragment from. |
pragma | string | 'h' | Element-creation function name. |
pragmaFrag | string | 'Fragment' | Fragment identifier. |
hoistStatic | boolean | true | Hoist static schemas (clone-cache keys). |
There is no optimize option.
Links
- npm: @exodra/babel-plugin-jsx
- GitHub: packages/babel-plugin-jsx