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.
6.1 Configuration
Section titled “6.1 Configuration”{ "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 fileThe 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.
6.2 What JSX produces
Section titled “6.2 What JSX produces”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.
6.3 Attribute aliases
Section titled “6.3 Attribute aliases”JSX attributes use camelCase (React convention). The runtime translates the common ones to their HTML / SVG equivalents:
| JSX | Output |
|---|---|
className | class |
htmlFor | for |
tabIndex | tabindex |
strokeWidth | stroke-width |
fillOpacity | fill-opacity |
xlinkHref | xlink: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).
6.4 Boolean attributes
Section titled “6.4 Boolean attributes”<input type="checkbox" checked={isOn} />checked={true}→checked(attribute present, no value)checked={false}→ omitted entirelychecked={null}/checked={undefined}→ omitted entirely
This matches HTML semantics — a boolean attribute is “on” by being present, regardless of its value.
6.4.1 Dangerous URL filter
Section titled “6.4.1 Dangerous URL filter”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.”
6.5 Children
Section titled “6.5 Children”<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 <, & becomes &, 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.
6.6 raw(html)
Section titled “6.6 raw(html)”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.
6.7 Fragment
Section titled “6.7 Fragment”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> );}6.8 Function components
Section titled “6.8 Function components”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.
6.9 Server-side use
Section titled “6.9 Server-side use”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.
6.10 Typed JSX intrinsic elements
Section titled “6.10 Typed JSX intrinsic elements”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.
Adding custom elements / web components
Section titled “Adding custom elements / web components”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:
| Type | Purpose |
|---|---|
KerfBaseAttrs | Common attributes valid on every HTML element (id, className, style, data-*, aria-*, …) |
KerfCustomElement | KerfBaseAttrs 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) |
AttrValue | The most permissive single value: string | number | boolean | null | undefined | SafeHtml |
DataAriaAttrs | data-* 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.
What does NOT work
Section titled “What does NOT work”declare global { namespace JSX { ... } }— kerf’s JSX namespace is module-scoped, not global. WithjsxImportSource: "kerfjs", TypeScript looks upJSXinsidekerfjs/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 inpackage.json#exports.