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/utils/jsx-attr-aliases.ts (the ATTR_ALIASES table)

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) — as long as the name is well-formed (see §6.4.2).

<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.

For the form-state trio — checked, value, selected — re-renders also carry the mutated attribute onto the live DOM property, so controlled form state keeps working after the user has interacted with the control (the browser’s dirty-state flags would otherwise detach the visible state from the attribute). See the render doc’s “Form-state properties” section for the full rules.

Enumerated attributes are not boolean attributes

Section titled “Enumerated attributes are not boolean attributes”

Six attributes read like booleans and are not: draggable, spellcheck, contenteditable, writingsuggestions, translate, and autocorrect. HTML calls them enumerated — they take literal keyword strings ("true" / "false" for the first four, "yes" / "no" for translate, "on" / "off" for autocorrect), and leaving them off selects a third state (auto for draggable, inherit-the-default for the rest). The boolean rendering above therefore lands on the wrong state:

You writeRendersElement ends up
draggable={true}<div draggable>not draggable — an empty value is invalid, so auto, and auto for a <div> is off
draggable={false}omittedauto again — and auto for <img> / <a href> is draggable
spellCheck={false}omittedspellchecking still on — omission means “inherit”, not “off”
contentEditable={false}omittedinside an editable region, still editable
writingsuggestions={false}omittedsuggestions still offered — omission means “inherit the default”
translate={false}omittedstill translated — omission means “inherit”, not “no”
autocorrect={false}omittedautocorrection still on — omission means “inherit the default”

So the types reject boolean on these six. Write the keyword:

<div draggable="true" />
<textarea spellCheck="false" />
<span contentEditable="false" />
<textarea writingsuggestions="false" />
<code translate="no" />
<input autocorrect="off" />

Omit the attribute when you want the default state. hidden, checked, disabled, autofocus, and the rest of the real boolean attributes are unaffected — hidden={isHidden} is still exactly right. So is popover: it is technically enumerated (auto / hint / manual), but the bare attribute’s empty value is a spec keyword for the auto state and omission means “not a popover”, so popover={true} / popover={false} both land exactly where they read — the boolean forms stay allowed alongside the keywords.

The fix is in the types rather than in the runtime deliberately. Translating {true}="true" would mean the renderer carrying a list of every enumerated attribute in HTML, and any attribute missing from that list would silently reproduce this same bug. A per-attribute type keeps the knowledge where the rest of the spec knowledge already lives, and costs nothing at runtime.

One case the types can’t reach: a signal-valued attribute (draggable={sig}) is opaque to the type system, so put the string in the signal — signal('true'), not signal(true).

Presence-or-value attributes take both forms

Section titled “Presence-or-value attributes take both forms”

A third shape sits between the two: attributes where the presence carries the meaning and a value refines it. download is the clearest case, and all three of its states are real and distinct:

<a href="/report.pdf" download /> {/* download; server names the file */}
<a href="/report.pdf" download="q3-summary.pdf" /> {/* download under this filename */}
<a href="/report.pdf" download={false} /> {/* ordinary navigation */}

capture on <input type="file"> is the same shape — bare means the default capture device, "user" / "environment" pick one. Both are typed boolean | string.

The test that tells this shape apart from an enumerated attribute: ask what {true} and {false} each render, then what those markups mean. Here they mean three different things. For draggable they collapse onto the same auto state, which is why boolean is rejected there and accepted here.

Two attributes are absent for the same reason: <select value> and <textarea value> don’t exist in HTML. A select’s selection lives on its options (<option value="b" selected>), and a textarea’s value is its child text (<textarea>{draft}</textarea>). Rendering a value attribute on either is inert markup the browser never reads, so the types don’t offer it.

Plain-string values written to URL-bearing attributes are screened by scheme. If a value resolves to a javascript: or vbscript: scheme, or a script-executing data: document type (data:text/html, data:image/svg+xml, XHTML/XML), kerf drops the attribute entirely. The screen runs on these attribute names: href, src, xlink:href, formaction, action, and data (the <object data> attribute).

How a drop surfaces depends on the build mode. In development the screen throws an Error with the full diagnostic, so a mistyped or unsafe URL fails loudly at your desk instead of vanishing into a console nobody reads. In production it does exactly what it always did — console.warns and drops the attribute — because a shipped app must never crash on attacker-influenced data. The attribute is dropped either way; only how the drop is reported differs. Mode is decided the same way as the rest of kerf’s dev diagnostics: importing kerfjs/dev selects the throwing behavior, omitting it selects warn-and-drop. kerf does not probe the environment. This is a dev-only change — production output is byte-identical to before.

Inert data: media — raster images (data:image/png, …), fonts, audio, video, and plain text/CSS — pass through, so <img src="data:image/png;base64,…"> still works. Every other data: subtype (including unknown ones) fails closed.

The javascript: no-op placeholders pass too. href="javascript:void(0)" and its handful of spellings — javascript:void(0);, javascript:void 0, javascript:;, and a bare javascript: — are the placeholder-link idiom, not an attack, and screening them was a false positive with a bad failure mode: the href was dropped, so the anchor stopped being a link (no keyboard focus, no :link styling, no pointer cursor) behind a console.warn nobody reads.

The match is against the whole normalized value, so nothing can ride along with one: javascript:void(0) passes and javascript:void(0);alert(1) does not. The carve-out is deliberately narrow for a second reason — the alternative, telling authors to write raw('javascript:void(0)'), teaches the general-purpose escape hatch as the answer to a benign case, and an author who has learned raw() for one href will reach for it on the next one.

(For what it’s worth, a link that goes nowhere is usually better written as <button type="button">. kerf doesn’t enforce that — a security screen is the wrong place for an accessibility opinion.)

The scheme match sees through the obfuscations a browser sees through — and then some: every C0 control character and DEL is stripped from anywhere in the value, then leading whitespace is trimmed, before the scheme is read. So java&#9;script:, a leading \x01, or javascript\x00: are all recognized and dropped, not just a clean javascript:. (Kerf is deliberately stricter here than the browser’s own TAB/LF/CR-only stripping.)

<a href={userInput}>click</a>
// userInput === 'javascript:alert(1)' → dev: throws; prod: 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 — dangerous schemes at non-URL attributes (data-action, custom attributes, etc.) pass through unchanged because they aren’t an attack surface there, and it does not cover HTML-bearing attributes like <iframe srcdoc> (which is HTML, not a URL — treat it like raw()).

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.”

Attribute values are escaped, but so are attribute names checked. The runtime validates every attribute name against a safe shape — a letter/underscore/colon followed by letters, digits, or _ . : - (which covers class, data-id, aria-label, xlink:href, stroke-width, viewBox, …). A name outside that shape throws:

const attrs = JSON.parse(untrustedConfig); // attacker controls the KEYS
<div {...attrs}></div> // a key like 'x><img onerror=…>' throws, not injects

This matters when you spread an object with untrusted keys into JSX (<div {...obj}>). Without the check, a malicious key could carry the >, =, quotes, or whitespace needed to break out of the open tag and inject markup — the attribute value being escaped doesn’t help if the name is the payload. Validate keys before spreading if they come from user data.

Inline event-handler attributes are also rejected — any on* name, whether the value is a function or a string, in any case:

<button onClick={fn}></button> // throws — use delegate() instead
<button onclick="doThing()"></button> // throws — a string here becomes a LIVE handler in the browser
<button onclick={someSignal}></button> // throws — a bound signal would setAttribute('onclick', …) → live handler

kerf’s model is event delegation, not inline handlers. A string like onclick="…" emitted into the HTML would become a real handler when parsed — an XSS vector if the value is attacker-controlled — so the runtime refuses it and points you at delegate().

Both name checks apply to every attribute path, not just static string values. A signal bound straight into an attribute (class={sig}) is written to the live element with setAttribute, and setAttribute('onclick', …) installs a real inline handler just as a parsed string would — so an on* (or malformed) name bound as a signal is rejected at binding time, before it can reach the DOM.

6.4.3 HTML-bearing attributes (srcdoc) are not URLs

Section titled “6.4.3 HTML-bearing attributes (srcdoc) are not URLs”

The dangerous-URL filter (§6.4.1) only covers attributes whose value is a URL. A few attributes instead hold HTML that a browser re-parses as a document — most notably <iframe srcdoc>. kerf escapes the attribute value correctly (so it’s well-formed markup, not a way to break out of the tag), but the iframe then decodes that value once and runs it as a document, so srcdoc={userString} executes attacker <script> even though the value was “escaped”:

<iframe srcdoc={userHtml} /> // ⚠️ userHtml is parsed as a document — like innerHTML, not like text

This is by design (it’s what srcdoc is for), the same footgun as React’s srcDoc. kerf does not reject srcdoc — trusted, app-generated srcdoc is a legitimate sandboxing pattern — so treat it like raw(): pass only markup you trust, and sanitize any user-supplied HTML upstream (DOMPurify) before it reaches srcdoc.

<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 roughly 100 HTML elements (the full sectioning / text / embedded / forms / tables / metadata / interactive sets) and the SVG primitives that toElement() supports. Misspelled tags (<diiv>) and misspelled attribute names (<input typo />) fail to compile.

Where the types come from. Names, value sets, and per-element membership are taken from the WHATWG HTML Living Standard and SVG 2, with MDN as a readable index into them — not from another framework’s table. That distinction is load-bearing: other tables describe a property surface (HTMLElement.draggable: boolean), while kerf emits content attributes into an HTML string, and the two disagree in exactly the places that cause silent bugs (see §6.4 on enumerated attributes). Coverage is focused rather than exhaustive — a missing attribute is a gap to fill, not a verdict that it’s invalid; add it via declaration merging until it lands upstream. The handful of deliberate departures from the spec — lowercase aliases alongside the camelCase forms, contentEditable="inherit", the obsolete presentational attributes kept as @deprecated — are enumerated with their reasons in src/jsx-types.ts’s header comment.

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.

6.11 Tagged templates — kerfjs/html (no build step at all)

Section titled “6.11 Tagged templates — kerfjs/html (no build step at all)”

JSX needs a transform. If your project has none — a CDN / importmap page, a <script type="module"> island, a quick prototype — author with the html tagged template instead. It lives at its own subpath so JSX-only apps don’t ship a byte of it:

import { html } from 'kerfjs/html';
const cls = signal('idle');
const count = signal(0);
mount(rootEl, () => html`
<div class="${cls}">Count: ${count}</div>
<ul>${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}</ul>
`);

html returns the same SafeHtml JSX produces, and every hole runs through the same runtime code paths as the equivalent JSX — not a lookalike reimplementation:

  • Text/child holes follow §6.5 exactly: strings are HTML-escaped, numbers stringify, null / undefined / booleans render nothing, arrays join, SafeHtml (nested html\`, raw(), and each() list segments) passes through — so a list hole is owned by the keyed reconciler just like in JSX. A signal/computedhanded in *itself* binds fine-grained (see §8.5 of the API reference and2-reactivity.md` §2.9); DOM nodes and other unsupported types throw the same errors.
  • Attribute holes follow §6.4 exactly: true renders the bare attribute, false / nullish omit it, SafeHtml values bypass the URL screen, plain strings are escaped and screened for dangerous URL schemes (§6.4.1), on* attributes and malformed names are rejected (§6.4.2), and a signal/computed binds the attribute fine-grained.

Two authoring differences from JSX:

  1. No camelCase aliases. Template authors write real HTML attribute names — class, for, tabindex, stroke-width — not className / htmlFor. The §6.3 alias table does not apply; an attribute name passes through verbatim.
  2. The hole contract. A ${…} hole is allowed in exactly two positions: a text/child position, or as the complete value of an attribute — attr=${v}, attr="${v}", or attr='${v}'. Everything else throws with a descriptive error at template evaluation:
html`<${tag}>…` // ✗ tag-name hole — write tag names statically
html`<div ${name}="x">…` // ✗ attribute-name hole — write names statically
html`<div class="a ${b}">…` // ✗ partial value — build the full string first,
// or bind computed(() => `a ${b.value}`)
html`<!-- ${note} -->` // ✗ hole inside a comment

The static parts of the template are author-written markup and pass through verbatim — the same trust model as JSX tag and attribute names. Only hole values are escaped/screened; never splice untrusted text into the static side of a template.

Performance: the static strings are parsed once per call site (tagged-template string arrays have stable identity, so the parse is cached in a WeakMap) and each render is a chunk walk with string concatenation — the same cost shape as the JSX runtime. Server-side (.toString(), outside mount()) works like JSX too: signal holes snapshot their current value.