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

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.

Five 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

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.