Skip to content

JSX runtime

kerf ships its own JSX runtime at kerfjs/jsx-runtime. JSX renders to SafeHtml — a small wrapper around an HTML string. There’s no virtual DOM, no element tree, no reconciliation tree. Just strings.

tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "kerfjs"
}
}

That’s the entire setup. The TypeScript / esbuild / vitest JSX transform looks for kerfjs/jsx-runtime and finds the jsx, jsxs, jsxDEV, and Fragment exports there.

const greeting = <p className="hi">Hello, world</p>;

The transform calls jsx('p', { className: 'hi', children: 'Hello, world' }), which returns a SafeHtml:

greeting.toString();
// → '<p class="hi">Hello, world</p>'

SafeHtml is just { __html: string; toString() }. Pass it to mount(), to toElement(), or call .toString() and write it into a server response.

JSX attributes use camelCase (React convention). The runtime translates the common ones to their HTML / SVG equivalents:

JSXOutput
classNameclass
htmlForfor
tabIndextabindex
strokeWidthstroke-width
fillOpacityfill-opacity
xlinkHrefxlink:href
…many more in src/jsx-runtime.ts

Anything not in the alias table is passed through verbatim. So data-action, aria-label, data-key all work as expected (JSX-to-HTML uses the literal attribute name).

<input type="checkbox" checked={isOn} />
  • checked={true}checked (attribute present, no value)
  • checked={false} → omitted entirely
  • checked={null} / checked={undefined} → omitted entirely

This matches HTML semantics — a boolean attribute is “on” by being present, regardless of its value.

<div>
Static text
{dynamicString} {/* HTML-escaped */}
{42} {/* number, no escaping */}
{someSafeHtml} {/* injected raw — already escaped by the producer */}
{[item1, item2]} {/* arrays joined */}
{null}{undefined}{false} {/* nothing rendered */}
</div>

Strings are HTML-escaped automatically. < becomes &lt;, & becomes &amp;, etc. No XSS surface.

SafeHtml children are injected raw. That’s the whole point — a sub-component returns SafeHtml, it composes without re-escaping.

DOM nodes throw. If you accidentally pass toElement(...) (a DOM node) as a child, the runtime throws a descriptive error. The runtime renders to strings; DOM nodes have no string equivalent.

For when you have a pre-escaped HTML string (rendered Markdown, sanitised user input, an SVG icon literal):

import { raw } from 'kerfjs';
const icon = raw('<svg ...><path d="..."/></svg>');
mount(rootEl, () => (
<button>
{icon}
Click me
</button>
));

raw() is new SafeHtml(html). The caller is responsible for ensuring the input is safe.

function MyList() {
return (
<>
<li>one</li>
<li>two</li>
</>
);
}

Renders without a wrapper tag. Just concatenates its children’s strings.

Fragment is also re-exported from the main kerfjs barrel — handy when you want to write <Fragment>...</Fragment> explicitly (rather than the <>...</> shorthand) or when a tool you’re integrating with expects to receive the symbol by name:

import { Fragment } from 'kerfjs';
function MyList() {
return (
<Fragment>
<li>one</li>
<li>two</li>
</Fragment>
);
}

A function component is a function that takes props and returns SafeHtml:

interface ButtonProps { label: string; action: string }
function ActionButton({ label, action }: ButtonProps) {
return <button data-action={action}>{label}</button>;
}
mount(root, () => (
<div>
<ActionButton label="Add" action="add" />
<ActionButton label="Reset" action="reset" />
</div>
));

The JSX transform invokes the function with the props it gathered; the function returns a SafeHtml; the parent JSX inlines it. There’s no instance, no lifecycle, no state — components are just JSX-string builders.

If you want stateful behaviour, the state lives in signals/stores OUTSIDE the component, and the component reads them:

import { signal } from 'kerfjs';
const count = signal(0);
function Counter() {
return <span>{count.value}</span>;
}

The mount() that hosts <Counter /> will re-render when count changes, which re-runs the component function.

SafeHtml.toString() works in any JS environment — Node, Deno, Bun, edge runtimes. There’s no DOM dependency. Build your page server-side, write the string into the response, then call mount() on the same root in the browser to wire up reactivity.