A UI compiler that
disappears at build time.
Apis.js components are single files of standard HTML, CSS and JavaScript. There's no virtual DOM and no framework runtime shipped to the browser — the compiler reads your component once and writes out the plain DOM calls that would have been tedious to hand-write.
<script>
let name = 'world';
function rename() {
name = 'user';
}
</script>
<h1>Hello {name.toUpperCase()}!</h1>
<button @click={rename}>Rename</button>
export default $runtime.makeComponent($option => {
const $$apply = $runtime.makeApply();
let name = 'world';
function rename() {
$$apply();
name = 'user';
}
const $el = $runtime.htmlToFragment(
`<h1> </h1><button>Rename</button>`, 1);
let [t, h1, btn] = $runtime.refer($el, '++a1.');
$runtime.bindText(t, () => `Hello ${name.toUpperCase()}!`);
$runtime.addEvent(btn, 'click', () => { rename(); $$apply(); });
return $el;
});
No virtual DOM diffing, no proxies wrapping your state. Reactivity is a small digest loop: every event handler ends by calling $$apply(), which re-runs the bindings that were registered during render and only touches the DOM where a value actually changed.
Assignment-driven
Write count++, not setState.
Zero runtime VDOM
Compiles to direct createElement/appendChild calls.
Standard syntax
HTML, CSS and JS — no new file format to learn.
Scoped CSS by default
:global() escape hatch when you need it.
Quick example
A small counter with a store-backed value, a reactive statement, and a transition — five of the ideas on this page in one file.
<script>
import { writable } from 'apisjs/runtime.js';
const count = writable(0);
let visible = true;
$: isEven = count.value % 2 === 0;
function inc() { count.update(n => n + 1); }
</script>
<button @click={inc}>Count: {count.value}</button>
{#if visible}
<p transition:fade={{ duration: 200 }} class:even={isEven}>
{isEven ? 'Even' : 'Odd'}
</p>
{/if}
<style>
.even { color: seagreen; }
</style>
Anatomy of a component
Every component is one file with up to three top-level blocks. None are required — a template-only file is a valid component.
| Block | Purpose |
|---|---|
<script> | State, functions, imports, lifecycle hooks, export let props |
| Template | Everything outside <script>/<style> — markup, interpolation, control flow |
<style> | CSS, scoped to this component by default |
Compile a source string programmatically with compile(source, config):
import { compile } from 'apisjs';
const ctx = await compile(componentSource, { cssGenId: () => `c${id++}` });
ctx.result; // compiled JS module, as a string
ctx.css.result; // extracted, scoped CSS, if any
Build tooling
In practice you don't call compile() by hand — a bundler plugin does it as part of your build.
import apis from 'apisjs/apis-rollup.js';
export default {
input: 'src/main.js',
plugins: [
apis({ extension: ['xht', 'html'] })
]
};
An esbuild-based dev workflow (apis-esbuild.js) is also included, with optional live-reload via an esbuild.config.js/derver.config.js pair in your project root.
Interpolation
Curly braces embed any JavaScript expression in text. It's re-evaluated on every digest, so it always reflects current state — no manual re-render call.
<h1>Hello {name.toUpperCase()}!</h1>
<p>{items.length} item{items.length === 1 ? '' : 's'}</p>
Elements & attributes
Attributes accept a static string, an {expr}, or a bare boolean flag.
<img src={avatarUrl} alt="Profile photo" />
<button disabled>Can't click me</button>
<button disabled={isSaving}>Save</button>
Conditionals
{#if user}
<p>Welcome, {user.name}</p>
{:else if loading}
<p>Loading…</p>
{:else}
<p>Please sign in</p>
{/if}
Elements entering or leaving an {#if} block are exactly where transition:/in:/out: attach — the block already waits for an outro to finish before removing the DOM node.
Loops
Destructure the item, grab the index, and give each row a stable key so reordering moves nodes instead of rebuilding them.
{#each todos as {text, done}, index (text)}
<li class:done>{index + 1}. {text}</li>
{:else}
<li>No todos yet</li>
{/each}
Async
{#await fetchUser(id)}
<p>Loading…</p>
{:then user}
<p>{user.name}</p>
{:catch error}
<p class="error">{error.message}</p>
{/await}
Events
Modifiers chain after a pipe: stop, prevent, ctrl/alt/shift/meta, and key filters like enter/esc/arrow keys.
<button @click={inc}>+1</button>
<form @submit|stop|prevent={save}>…</form>
<input @keyup|enter={search} />
Two-way binding
<input :value={name} />
<input type="checkbox" :checked={agreed} />
<input type="number" :value={age} />
Actions — use:
An action is a plain function that runs once when its element mounts, and optionally returns a cleanup function. It's the escape hatch for imperative DOM behavior that doesn't warrant a whole child component.
export function tooltip(node, message) {
const show = () => node.setAttribute('title', message);
show();
return () => node.removeAttribute('title');
}
<div use:tooltip={message}>Hover me</div>
Transitions
transition: plays both ways; in:/out: play one direction only. Four built-ins ship in the runtime: fade, fly, scale, slide — each is a plain function returning { duration, delay, easing, css }, so writing your own is the same shape.
<script>
import { fade, fly } from 'apisjs/runtime.js';
let visible = true;
</script>
{#if visible}
<p transition:fade={{ duration: 200 }}>Hello</p>
{/if}
<div in:fly={{ y: -20 }} out:fade>Toast</div>
Outro transitions don't need special-casing in #if/#each — they hook into share.destroyResults, the same promise-based "wait before removing" mechanism those blocks already use for async cleanup. Return a promise from an $onDestroy callback and removal waits for it.
Class & style shorthands
<div class:active class:disabled={isDisabled}>…</div>
<div style:color={theme.color} style:font-size="14px">…</div>
The digest loop
There's no compile-time tracking of which statements touch which variables. Instead, every compiled @event handler ends with $$apply():
$runtime.addEvent(el, 'click', () => { inc(); $$apply(); });
$$apply() walks the component's change-detector tree and re-runs every registered binding's getter ($watch(fn, cb)); a getter whose return value changed fires its callback — a DOM update, a prop push into a child, a store write. It's dirty-checking, not a virtual DOM diff: no tree reconciliation, just "did this specific expression's value change."
Reactive statements — $:
let count = 0;
$: doubled = count * 2;
$: if (count > 10) console.log('big!');
Stores
Any imported value with a .subscribe(fn) method is auto-subscribed by the compiler — subscribing re-runs the component's digest whenever the store notifies. writable/readable/derived implement that contract so you don't hand-roll it.
import { writable, derived } from 'apisjs/runtime.js';
export const count = writable(0);
export const doubled = derived(count, n => n * 2);
<script>
import { count, doubled } from './store.js';
</script>
<button @click={() => count.update(n => n + 1)}>
{count.value}
</button>
<p>Doubled: {doubled.value}</p>
Templates read .value directly rather than unwrapping via a $store sugar — one less piece of magic to learn.
Context
$context is available in every component's script without an import — a plain object shared down the component tree, for state that would otherwise mean prop-drilling through several layers.
// anywhere in the subtree
$context.theme = 'dark';
console.log($context.theme);
Props & slots
export let declares a reactive prop; export const declares one that's read once and never pushed to again.
<script>
export let title;
export const variant = 'default';
</script>
<div class="card {variant}">
<h3>{title}</h3>
{#slot}{/slot}
</div>
<Card title="Hello">
<p>Card body content</p>
</Card>
Named, scoped slots pass a prop back into the parent's slot content:
{#each items as item}
{#slot:row item}{/slot}
{/each}
<List items={items}>
{#slot:row item}
<li>{item.label}</li>
{/slot}
</List>
Scoped CSS
Styles in <style> are scoped to the component automatically — no BEM, no CSS-in-JS. Reach outside with :global().
<div class="wrap">{#slot}{/slot}</div>
<style>
.wrap { padding: 1rem; }
:global(body) { margin: 0; }
</style>
Mount, destroy & tick
$onMount(() => {
console.log('mounted');
return () => console.log('cleanup, runs on destroy');
});
$onDestroy(() => console.log('destroyed'));
$tick().then(() => console.log('DOM has been updated'));
Roadmap & TypeScript
Everything above this line is implemented and has been verified by actually compiling and running the example. Two things worth calling out explicitly:
| Item | Status |
|---|---|
Stores (writable/readable/derived) | Shipped |
Transitions (transition:/in:/out:) | Shipped |
animate: for FLIP list reordering | Planned |
TypeScript (<script lang="ts">) | Planned — type erasure only |
| SSR / hydration | Not started |
The TypeScript plan is type erasure, not in-compiler type-checking: strip types at compile time (via the typescript package's transpileModule) and hand the resulting plain JS to the existing parser unchanged. Real type-checking stays the editor's or tsc's job — the same division Svelte and Vue SFCs use.