@runefw/rune
v0.1.6
Published
A compiler-driven frontend framework experiment.
Readme
@runefw/rune
Rune lets you write reactive UI with normal TypeScript variables. Write components in .rune files, write reusable logic or app entries in *.rune.ts, and Rune compiles the reactive behavior for Vite apps.
API Index
| API | Example |
|-----------------------------------------------------------------|---------------------------------------------------------------------------------|
| prop | Props Example |
| emit | Events Example |
| let / var reactive state | Reactive State Example |
| $computed | Computed Example |
| $effect | Effect Example |
| $watch | Watch Example |
| $batch | Batch And Untrack Example |
| $untrack | Batch And Untrack Example |
| $readonly | Readonly Views Example |
| $shallowReadonly | Readonly Views Example |
| $shallowAuto | Readonly Views Example |
| $mounted | Lifecycle And Owned Callback Example |
| $cleanup | Lifecycle And Owned Callback Example |
| $owned | Lifecycle And Owned Callback Example |
| template -> (...) | Template Example |
| if / for / key | Template Example |
| template expression functions | Template Expression Functions Example |
| {await ...} | Await Blocks Example |
| on:* | Directives Example |
| bind:* | Directives Example |
| class:* | Directives Example |
| style:* | Directives Example |
| use:* | DOM Action Example |
| <rune:slot> | Built-in Tags Example |
| <rune:fragment> | Built-in Tags Example |
| <rune:component> | Built-in Tags Example |
| #text | Raw Text And HTML Example |
| #html | Raw Text And HTML Example |
| style -> {} / style.css -> {} / style.scss / style.less | Scoped Style Example |
| RuneAttrs<Tag> | DOM Attrs Example |
| use* composables | Composable Example |
| $render | Render Entry Example |
Related Packages
| Package | Purpose |
|------------------------------------------------------------------------------------|-------------------------------------------------|
| @runefw/create | Project scaffolder for Rune apps and libraries. |
| @runefw/prettier-plugin | Prettier integration for .rune files. |
| @runefw/formatter | Shared formatter core used by Rune tooling. |
Examples
Component Basics Example
prop string label
let count = 0
function increment() {
count += 1
}
template -> (
<button on:click={increment}>
{label}: {count}
</button>
)Props Example
prop declares parent-to-child inputs. Props are deep readonly inside the child component.
prop string title
prop number count = 0
prop string subtitle?
prop user = {
name: "Rune",
age: 0
}
template -> (
<article>
<h2>{title}</h2>
<p>{subtitle ?? "No subtitle"}</p>
<p>{user.name}: {count}</p>
</article>
)prop name = default is inferred by TypeScript. Props without a default value must use an explicit type.
Events Example
emit declares child-to-parent events.
// TextField.rune
prop value = ""
emit string Change
function update(event: Event) {
const input = event.currentTarget as HTMLInputElement
Change(input.value)
}
template -> (
<input value={value} on:input={update} />
)Parents listen with component events.
// Parent.rune
import TextField from "./TextField.rune"
template -> (
<TextField on:Change={(nextValue) => console.log(nextValue)} />
)Reactive State Example
Top-level let and var bindings are reactive state. Objects, arrays, Map, and Set are deep reactive by default.
let count = 0
let user = { name: "Rune" }
let users = new Map<string, { name: string }>()
function rename() {
count += 1
user.name = "Compiler"
users.set("a", { name: "Ada" })
users.get("a")!.name = "Grace"
}
template -> (
<button on:click={rename}>
{user.name}: {count}
</button>
)WeakMap and WeakSet stay ordinary values because they cannot be traversed for deep tracking.
Computed Example
Use $computed for derived state.
let count = 0
const doubled = $computed(() => count * 2)
template -> (
<button on:click={() => (count += 1)}>
{count} / {doubled}
</button>
)Effect Example
Use $effect for side effects that track reactive reads.
let count = 0
$effect(() => {
console.log("count changed", count)
return () => console.log("previous effect disposed")
})
template -> (
<button on:click={() => (count += 1)}>
Count {count}
</button>
)Watch Example
Use $watch when you want a callback only after a source changes, with both the next and previous values.
let count = 0
let user = { name: "Rune" }
$watch(count, (next, previous) => {
console.log("count", next, previous)
})
$watch(() => user.name, (name, oldName) => {
console.log("name", name, oldName)
})
$watch(user, (nextUser) => {
console.log("deep by default", nextUser.name)
})
$watch(user, (nextUser) => {
console.log("reference changes only", nextUser)
}, { deep: false })
$watch(user, () => {
console.log("direct fields changed")
}, { deep: 1 })
$watch.post(count, (next) => {
console.log("after DOM update", next)
})
template -> (
<button on:click={() => (count += 1)}>
{user.name}: {count}
</button>
)Batch And Untrack Example
Use $batch to group writes and $untrack to read without subscribing the current effect.
let first = "Ada"
let last = "Lovelace"
function rename() {
$batch(() => {
first = "Grace"
last = "Hopper"
})
}
$effect(() => {
console.log("tracked", first)
console.log("snapshot", $untrack(() => last))
})
template -> (
<button on:click={rename}>
{first} {last}
</button>
)Readonly Views Example
Use readonly views when you want to share reactive data without exposing mutation.
let state = {
user: { name: "Rune" },
count: 0
}
const readonlyState = $readonly(state)
const shallowState = $shallowReadonly(state)
const shallowReactive = $shallowAuto({ active: true })
template -> (
<p>
{readonlyState.user.name}: {shallowState.count} / {shallowReactive.active ? "active" : "idle"}
</p>
)Lifecycle And Owned Callback Example
Use $mounted after DOM insertion, $cleanup for owner cleanup, and $owned when a callback runs later but still needs the current cleanup owner.
let button: HTMLButtonElement | undefined
let clicks = 0
const subscribeLater = $owned(() => {
const timer = window.setInterval(() => {
clicks += 1
}, 1000)
$cleanup(() => window.clearInterval(timer))
})
$mounted(() => {
button?.addEventListener("click", subscribeLater)
return () => button?.removeEventListener("click", subscribeLater)
})
template -> (
<button bind:this={button}>
Started {clicks} timers
</button>
)Template Example
Templates use HTML-like markup with JavaScript expressions and control flow.
let items = [
{ id: 1, label: "Compiler" },
{ id: 2, label: "Runtime" }
]
template -> (
<section>
if (items.length === 0) {
<p>No items</p>
} else {
for (const item of items) {
<p key={item.id}>{item.label}</p>
}
}
</section>
)key={expr} is compiler-only list identity. It is not emitted as a DOM attribute or component prop.
Template Expression Functions Example
Template expressions can use TSX-like arrow callbacks, ordinary function callbacks, and IIFEs that return Rune template nodes. Returning null, false, or undefined renders an empty branch.
type User = { id: string; name: string; visible: boolean }
let users: User[] = [
{ id: "ada", name: "Ada", visible: true },
{ id: "grace", name: "Grace", visible: false }
]
let user: User = { id: "lin", name: "Lin", visible: true }
template -> (
<section>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<ul>
{users.map(function (user) {
return user.visible ? <li key={user.id}>{user.name}</li> : null
})}
</ul>
{(() => {
if (!user.visible) return null
return <article>{user.name}</article>
})()}
</section>
)These are Rune template expression forms. Rune does not enable a TSX runtime for ordinary TypeScript value positions.
Await Blocks Example
Use {await ...} as a template expression block for Promise state. The pending, resolved, and rejected branches all render real Rune template nodes.
type User = { name: string }
let userId = "ada"
function fetchUser(id: string): Promise<User> {
return Promise.resolve({ name: id })
}
template -> (
<section>
{await fetchUser(userId) {
<p>Loading...</p>
} then (user) {
<p>{user.name}</p>
} catch (error) {
<p>Failed</p>
}}
</section>
)then receives the resolved value. catch receives an unknown error value. When the Promise source changes, stale resolve or reject results are ignored.
Unsupported forms include naked await children, <p>{await promise}</p>, async .map(...) callbacks that return templates, and loading-only await blocks without then.
Directives Example
Rune template directives are compiler features for native DOM elements.
let name = ""
let active = true
let accent = "#2563eb"
template -> (
<label class:active style:color={accent}>
Name
<input
bind:value={name}
on:keydown.enter={() => (active = !active)}
/>
</label>
)When the directive name is a JavaScript identifier, class:active means class:active={active} and style:color means style:color={color}. Use an explicit value for kebab-case names and CSS custom properties, such as class:is-active={active} or style:--accent={accent}.
DOM Action Example
use:name calls the same-named action function with the DOM node. If the directive has a value, its second argument is a getter that can be passed to $watch.
let message = "Save"
function tooltip(node: HTMLElement, text: () => string) {
$watch(text, (value) => {
node.dataset.tooltip = value
}, { immediate: true })
$cleanup(() => {
delete node.dataset.tooltip
})
}
template -> (
<button use:tooltip={message}>Save</button>
)Actions use $watch or $effect for updates and $cleanup for disposal; they do not return an update / destroy object.
Built-in Tags Example
Rune built-in tags use the rune: namespace.
// Panel.rune
template -> (
<article>
<header><rune:slot name="header">Fallback header</rune:slot></header>
<main><rune:slot /></main>
</article>
)// DetailsPanel.rune
prop string title
template -> (
<p>{title}</p>
)// Page.rune
import Panel from "./Panel.rune"
import DetailsPanel from "./DetailsPanel.rune"
let title = "Overview"
let CurrentPanel = DetailsPanel
template -> (
<Panel>
<rune:fragment slot="header">
<h2>{title}</h2>
</rune:fragment>
<rune:component is={CurrentPanel} title={title} key={title} />
</Panel>
)Raw Text And HTML Example
Use #text for safe raw text and #html for trusted raw HTML.
let safeText = "<strong>shown as text</strong>"
let trustedHtml = "<strong>rendered as HTML</strong>"
template -> (
<article>
<pre>#text { $1 }(safeText)</pre>
<div>#html { $1 }(trustedHtml)</div>
</article>
)#html does not sanitize or escape content. Only use it with trusted or application-sanitized HTML.
Scoped Style Example
style -> {} and style.css -> {} define component-scoped CSS. style.scss, style.less, and other Vite CSS preprocessor blocks are compiled to CSS first, then scoped by Rune.
let accent = "#2563eb"
template -> (
<article class="card">
Scoped styles
</article>
)
style -> {
.card {
border: 1px solid #d1d5db;
color: {accent};
}
:global(body) {
margin: 0;
}
}Selectors are scoped to native DOM elements declared by the component template. Styles do not pierce child components.
Preprocessor syntax belongs to the preprocessor, not Rune:
template -> (<button class="button" style:--button-color={accent}>Save</button>)
style.scss -> {
@use "./tokens.scss" as *;
.button {
color: var(--button-color);
border-color: $border;
}
}Rune {expr} style interpolation is only supported in style -> {} / style.css -> {}. In preprocessor blocks, pass reactive values through CSS custom properties with style:*.
DOM Attrs Example
Use RuneAttrs<Tag> for DOM attribute passthrough props.
prop string label
prop RuneAttrs<"input"> inputProps = {}
template -> (
<label>
{label}
<input {...inputProps} />
</label>
)Composable Example
*.rune.ts files can define composables. Functions named use* follow React Hooks-style stable call rules.
// useCounter.rune.ts
export function useCounter(initial = 0) {
let count = initial;
const doubled = $computed(() => count * 2);
function increment() {
count += 1;
}
return { count, doubled, increment };
}Import the composable with a Rune specifier.
import { useCounter } from "./useCounter.rune";
const counter = useCounter();
template -> (
<button on:click={counter.increment}>
{counter.count} / {counter.doubled}
</button>
)Do not call composables in conditions, loops, event handlers, template expressions, or ordinary nested functions.
Render Entry Example
Applications render from a *.rune.ts entry file.
// main.rune.ts
import App from "./App.rune";
$render(App, "#app");$render is only valid at the top level of a Rune-aware TypeScript entry module.
