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.

Mixing kerf with another JSX runtime (e.g. React). A project can only set one jsxImportSource default, so when kerf coexists with React in the same codebase, override per file with the standard TypeScript pragma — a block comment on the first line:

/** @jsxImportSource kerfjs */ // top of a kerf file
/** @jsxImportSource react */ // top of a React file

The pragma is honored by tsc, esbuild, Vite, and swc. Set the tsconfig default to whichever runtime owns more files and pragma the rest. This is the foundation of incremental migration — see 10-migrating.md and the /kerf/migrating/incremental/ guide.

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.

Plain-string values written to URL-bearing attributes are screened against a small allow-list. If a value starts with javascript:, vbscript:, or data:text/html (case-insensitive, leading whitespace tolerated), kerf drops the attribute entirely and console.warns. The screen runs only on these attribute names: href, src, xlink:href, formaction, action.

<a href={userInput}>click</a>
// userInput === 'javascript:alert(1)' → rendered as <a>click</a>, warning logged
// userInput === 'https://example.com' → rendered as <a href="https://example.com">click</a>

The screen exists so a stored-XSS payload reaching a href={...} interpolation cannot turn into a clickable script vector. It is not a general sanitizer — javascript:/vbscript: URLs at non-URL attributes (data-action, custom attributes, etc.) pass through unchanged because they aren’t an attack surface there.

SafeHtml (i.e. raw()) values bypass the screen — that’s the documented escape hatch:

import { raw } from 'kerfjs';
// Bookmarklet builder, sanitized-upstream input, etc.
<a href={raw('javascript:doStuff()')}>bookmarklet</a>

If you find yourself reaching for raw() on URLs that came from users, route them through a real sanitizer (DOMPurify, Linkify, etc.) first; raw() is “I take responsibility for this string”, not “skip the safety net.”

<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, sanitized 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 behavior, 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.

To ship reusable components as npm packages — including the per-instance-state, event, and packaging considerations — see 13-component-packages.md.

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.

The JSX transform looks at JSX.IntrinsicElements in kerfjs/jsx-runtime to type-check tags and attributes. The table covers the ~30 most common HTML elements and the SVG primitives that toElement() supports. Misspelled tags (<diiv>) and misspelled attribute names (<input typo />) fail to compile.

The framework uses module augmentation, not a global namespace. Open the kerfjs/jsx-runtime JSX namespace and add your tag:

import type { KerfCustomElement } from 'kerfjs/jsx-runtime';
declare module 'kerfjs/jsx-runtime' {
namespace JSX {
interface IntrinsicElements {
'my-element': KerfCustomElement & {
foo?: string;
bar?: number;
};
}
}
}
// Now `<my-element foo="hi" bar={3} />` typechecks.

KerfCustomElement is a permissive base that extends KerfBaseAttrs and admits any extra attribute. Tighten it for your project by listing the attributes explicitly. The building-block types are all re-exported from kerfjs/jsx-runtime:

TypePurpose
KerfBaseAttrsCommon attributes valid on every HTML element (id, className, style, data-*, aria-*, …)
KerfCustomElementKerfBaseAttrs plus an open index signature — for unknown / loose web components
AttrLike<T>An attribute value typed as T plus the runtime fall-throughs (SafeHtml, null, undefined)
AttrValueThe most permissive single value: string | number | boolean | null | undefined | SafeHtml
DataAriaAttrsdata-* and aria-* index signatures, applied via KerfBaseAttrs

Worked example: a Lit-style custom element

Section titled “Worked example: a Lit-style custom element”

Suppose your app uses a third-party <x-toast> web component from @example/toast that exposes variant, dismissible, and duration attributes. Wire it into the kerf type system once, then use it from JSX everywhere with full attribute checking:

// types/x-toast.d.ts (or anywhere the TypeScript project sees)
import type { AttrLike, KerfCustomElement } from 'kerfjs/jsx-runtime';
declare module 'kerfjs/jsx-runtime' {
namespace JSX {
interface IntrinsicElements {
'x-toast': KerfCustomElement & {
variant?: AttrLike<'info' | 'success' | 'warning' | 'error'>;
dismissible?: AttrLike<boolean>;
duration?: AttrLike<number>;
};
}
}
}

Then in any JSX file:

import { mount, signal } from 'kerfjs';
const visible = signal(true);
mount(rootEl, () => visible.value ? (
<x-toast variant="success" duration={3000} dismissible>
Saved.
</x-toast>
) : null);
// Type errors fire on misuse:
// <x-toast variant="rainbow" /> // error: not assignable to 'info' | 'success' | …
// <x-toast typo="value" /> // also catches typos? — only if you remove `KerfCustomElement`
// // and switch to `KerfBaseAttrs` + explicit attrs.

KerfCustomElement is the permissive base — it accepts any extra attribute beyond what you enumerate. Tighten it to KerfBaseAttrs & { …explicit attrs } if you want typos on this tag to error out the same way <inptu> does.

For Stencil / Lit / Solid-js custom-element libraries, repeat the pattern once per tag the app uses (or pull the type definitions from the library’s published types if it ships them). The augmentation is project-side, so you can extend it incrementally as new tags appear.

  • declare global { namespace JSX { ... } } — kerf’s JSX namespace is module-scoped, not global. With jsxImportSource: "kerfjs", TypeScript looks up JSX inside kerfjs/jsx-runtime, not the global scope. The merge above is the only working form.
  • Importing from kerfjs/jsx-types — that’s an internal module and is intentionally not in package.json#exports.