Skip to content

DSL & directives

DSL stands for Domain Specific Language — the DX layer of Phaze, enabled in God Mode.

In Phaze, directives can be thought of as a “headless component” that adds reactive behavior to a DOM element. The core directives phaze:, on:, class:, bind: don’t add any additional size — use: directives can be thought of as “add-ons”. Directives stack and keep things much cleaner. Phaze has been built with performance from the ground up — directives are tree-shaken per-import, so you pay only for the use: directives you attach. For caching granularity in production, directives can also be split into their own chunk via chunkDirectives: true, bundled as phaze-directives.js.

The DSL subpath — @madenowhere/phaze/dsl

Section titled “The DSL subpath — @madenowhere/phaze/dsl”

Four exports, all compile-time-aware:

NameAliasesWhat the phaze-compiler does
ssignalPlain alias — no transform. Carries s.await (compile-only) and s.async / s.bind from the signal factory
ccomputedAuto-thunks: c(expr) → c(() => expr)
watcheffectAuto-thunks: watch(expr) → watch(() => expr)
phaze(macro)Rewrites phaze(expr) → () => expr, drops the import
import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'
import { cleanup } from '@madenowhere/phaze'
export function Counter() {
const count = s(0)
const doubled = c(count() * 2) // auto-thunked
watch(console.log(`count: ${count()}`)) // auto-thunked
cleanup(() => console.log('unmounted')) // imported normally
return (
<button on:click={() => count.update(n => n + 1)}>
{count} × 2 = {doubled}
</button>
)
}

Equivalent without the DSL:

import { signal, computed, effect, cleanup } from '@madenowhere/phaze'
const count = signal(0)
const doubled = computed(() => count() * 2)
effect(() => console.log(`count: ${count()}`))

The two are transformed by the phaze-compiler to the same code. The DSL is purely an authoring convention — pick the import style your project prefers.

The phaze() macro — reactive child expressions

Section titled “The phaze() macro — reactive child expressions”

Bare signal reads in JSX ({count}) are the fast path: the runtime detects the callable and wires it to a text/attribute binding directly. The moment you need to do something to the value — arithmetic, formatting, comparison — wrap in phaze():

<p>{count}</p> {/* bare — fast path */}
<p>{phaze(`Total: $${total()}`)}</p> {/* template literal */}
<p>{phaze(count() > 5 ? 'high' : 'low')}</p> {/* expression */}

phaze(expr) rewrites to () => expr; the import drops out and the macro body tree-shakes. Same bytes as writing the arrow yourself.

The phaze-compiler also auto-wraps ternaries and && expressions in JSX child position, so {cond() ? <A/> : <B/>} is reactive without phaze(). The wrapper is for cases the auto-wrap doesn’t cover — most commonly template literals.

s.await({ steps }) is a triggered, sequential pipeline: named steps run in order, each step’s result threading into the next, with one shared failure state. Idle until .run(payload).

const register = s.await({
registerStart: actions.Passkey.registerStart,
createPasskey: createPasskey,
registerFinish: actions.Passkey.registerFinish,
done: [({ createPasskey }) => auth.credId = createPasskey.id, auth.save()]
})
<button on:click={register.run()}>Register passkey</button>
<p>{register.match({ pending: 'waiting for passkey…', error: (e) => e.message })}</p>

Sequencing is the cheap half — async/await already does that. The point is what the sequence is:

s.await is sequencing whose intermediate states are reactive values, scoped to the component that owns them.

Phaze drives the DOM through fine-grained bindings: a component runs once, and each reactive read wires straight to the node it feeds, so an update touches exactly those nodes — no VDOM, no diffing, no re-render pass. Every value reaches the screen the same way, as a signal, so a process’s states are signals too.

Building on signal.async inherits that machinery: real signals behind match, effect() scoping so disposal cancels in-flight work, abortSignal() inside steps. The trigger is a signal as well — .run() is a write, which is why it composes inside watch(…), why retry is just another run, and why idle exists at all (trigger() ? runSteps() : null).

Step arms. A callable arm (a bare reference or an arrow) is called with the threaded value — registerStart above receives the payload, createPasskey receives what it returned. Any other expression — including a call — evaluates at its turn, so no () => is needed to defer it:

approve: actions.QR.approve({ code: data.code }) // evaluates when its turn comes

What the compiler writes around every step, which is most of what the DSL buys:

Step resolves toFlow does
{ data, error } (an action envelope)unwraps — error becomes the flow’s error, data threads on
an Errorthat error state, no throw needed — check: (r) => r.ok ? r : new Error('rejected')
null / undefinedcancels back to idle — a dismissed ceremony is not a failure
a rejection named NotAllowedErroridle, not error (the WebAuthn dismissal)

The done arm runs last and never threads. It takes one effect, or a list of them, awaited in source order under the same callable-vs-expression rule — so only the element that needs a step result pays for an arrow. A spread or an empty list is a compile error: the effects must be statically ordered.

Reader surface: .pending() (fired and loading — false at birth), .error(), .value(), .run(payload?), .reset() (back to true idle), and .match({ pending, error, ready }).

Bundle cost: 0. There is no runtime await member — the built dsl.js is export const s = signal. The whole machine emits inline from primitives that already ship (s, s.async), so an app that uses ten flows adds no library. Unlike signal.async, there is no hand-written equivalent form: without phaze-compile, s.await is not a function.

import { signal as s } from '@madenowhere/phaze' is not the same as import { s } from '@madenowhere/phaze/dsl'. The first is a plain rename; the second activates phaze-compile‘s auto-thunking. The transform is keyed on the import path, not the local name.

Six prefixes, each owning one job:

NamespacePurposeTransformed via phaze-compiler
phaze:attr={expr}Reactive attributeattr={() => expr}
on:event={fn}Native DOM event listeneronEvent={fn} (camelCase); CallExpression values auto-thunk to () => …
use:name={value}Behavior directivename(el, () => value) post-creation
class:name={cond}Conditional class toggleeffect(() => el.classList.toggle('name', !!cond)) post-creation
bind:value={signal} / bind:checked={signal}Two-way input binding (minimal scope)Signal pass-through + listen() post-creation
nav:scroll / nav:transitionPer-navigation link optionsdata-nav-* attribute — static stays literal, reactive takes the phaze: thunk

All five namespaces compose on a single element. Post-creation operations (use:, class:, bind:) run in source order inside one IIFE; phaze: and on: rewrite in place to standard attributes:

<input
type="text"
class="input"
use:autofocus={true}
class:invalid={!valid()}
bind:value={text}
on:focus={track}
/>
Transformed via phaze-compiler (approximate)
((__el) => (
autofocus(__el, () => true),
effect(() => __el.classList.toggle('invalid', !valid())),
listen(__el, 'input', (e) => text.set(e.currentTarget.value)),
__el
))(<input type="text" class="input" value={text} onFocus={track} />)

The phaze:/on:/use:/class:/bind: namespaces and the DSL auto-thunking require phaze-compile in your build pipeline.

If you only want the runtime — no DSL, no namespaces — @madenowhere/phaze alone works with any tooling that supports the standard JSX automatic transform. See Setup.