2. phaze-compile
1. phaze-tsplugin ← editor (TS Language Service)2. phaze-compile ← build-time AST rewriting ← you are here ├── babel-plugin.ts ← the actual Babel plugin (all the visitors live here) └── vite-plugin.ts ← thin wrapper that adapts it for Vite3. phaze-vite ← island HMR + chunking helpers4. phaze-astro ← Astro integration (island model)5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)phaze-compile is the engine of phaze’s “compile-time ergonomics” thesis. Every feature that’s been added to make Phaze code shorter than the equivalent React or generic-signals code — c(expr) instead of c(() => expr), <For for:todo={todos}> instead of <For each={() => todos()} getKey={…}>, inc(count) instead of count.set(count() + 1), <input bind:value={name}/> instead of the manual value+onInput pair — is implemented as an AST rewrite in this package.
For the full per-transform catalog with side-by-side source/compiled examples, see DSL & directives. This page covers the why and the how — how the package is organized, why it uses Babel, what the two source files (babel-plugin.ts and vite-plugin.ts) each do — plus the at-a-glance transform index and diagnostics summary.
The package’s two surfaces
Section titled “The package’s two surfaces”@madenowhere/phaze-compile exposes two primary entry points from the same codebase (plus several supporting subpaths — see the export map below):
@madenowhere/phaze-compile/├── src/│ ├── babel-plugin.ts ← THE ENGINE — Babel plugin with the AST visitors│ ├── vite-plugin.ts ← THE ADAPTER — Vite plugin that wraps the Babel one│ └── index.ts ← entry: re-exports the Babel plugin (default) + Vite plugin (`vite`)└── package.json (exports): "." → Babel plugin (default) + `vite` (named) "./babel-plugin" → the standalone Babel plugin "./vite" → the Vite plugin wrapper "./phaze-format" → `.phaze` parser + emit + v3 sourcemap (+ `/lift-title`) "./global-registry" → the project-wide `@global` registry "./flatbuffers" → flatc-reader codegen helpers (+ `/runtime`) "./action-fb" → the action registry (per-action spec map: FB + sign/encrypt + direct) + FB accessor plumbing| Import path | What it returns | Used by |
|---|---|---|
@madenowhere/phaze-compile | The Babel plugin (default export) | Raw Babel pipelines, custom Rollup babel passes, jest’s babel-jest, non-Vite consumers |
@madenowhere/phaze-compile/vite | A Vite plugin object that wraps the Babel plugin | Vite / Astro consumers (and phaze-astro imports this internally) |
Two primary surfaces, one set of transforms. The Vite wrapper began as a thin transform shim but is now ~475 lines: besides running babel.transformSync() over .tsx/.jsx, it pre-processes .phaze files (parse → synthetic TSX), tags .phaze ids with ?lang.tsx so Oxc lowers their JSX, resolves per-device variant virtual modules (?variant=…), scans and maintains the @global registry, and wires HMR.
What it transforms
Section titled “What it transforms”Eight categories of transform. Each one is documented in detail in DSL & directives; this is the one-liner-per-transform index plus the why-it-matters.
| Source | Transformed via phaze-compiler | What it saves you |
|---|---|---|
c(expr) from /dsl | c(() => expr) (auto-thunked) | The () => ceremony at every computed declaration |
watch(expr) from /dsl | effect(() => expr) (auto-thunked + alias) | Same, plus the effect → watch rename for readability |
phaze(expr) from /dsl | (() => expr) (macro — the import drops out) | Reactive child-expressions without writing the arrow |
s.async(expr) from /dsl | s.async(() => expr) (auto-thunked) | Async-loader thunk ceremony at every s.async declaration |
inc(sig) / dec(sig) / add(sig, n) / sub(sig, n) from /numeric | sig.set(sig() ± n) (inlined, import declaration drops out) | The n => n + 1 updater-function ceremony on number signals; zero bytes shipped from /numeric after compilation |
interval(delay, expr) / timeout(delay, expr) from /time | interval(delay, () => expr) (second-arg auto-thunked) | Tick-callback () => ceremony; lets interval(1000, inc(count)) work as written |
interval(restart, delay, fn) / timeout(restart, delay, fn) from /time | interval(() => { restart(); return delay }, () => fn) (3-arg sugar; import-time-only, no runtime fallback) | The full () => { signal_read(); return ms } ceremony for debounce-shaped patterns; turns timeout(draft, 1000, saveDraft()) into the canonical form at build. Misuse (literal restart, function-shaped delay, spread args) is caught with descriptive compile-time errors. |
phaze:attr={expr} | attr={() => expr} | Reactive attribute bindings via plain JSX prop syntax |
on:event={fn} | onEvent={fn} (camelCase rename) | Visual alignment with the other namespaces |
on:event={callExpr} | onEvent={() => callExpr} (auto-thunked, DEV-only factory warning injected) | Inline event handlers as bare expressions: on:click={state.set('s2')} |
use:NAME={value} | ((__el) => (NAME(__el, () => value), __el))(<jsx/>) (post-creation IIFE) | Behavior directives attached to elements without ref ceremony |
use:spring={IDENT[KEY]} (sibling springs key present) | Same IIFE, value rewritten in-place to { to: IDENT[KEY], springs: IDENT.springs } (auto-fuse) | State-machine spring configs as one record + one JSX line |
class:NAME={cond} | effect(() => __el.classList.toggle('NAME', !!cond)) (post-creation) | Conditional class toggles via JSX-native syntax; effect import auto-injected |
bind:value={signal} (text-like inputs / textarea) | value={signal} pass-through + onInput={(e) => signal.set(e.currentTarget.value)} | Two-way binding for the trivial cases, compile-error for the non-trivial ones |
bind:checked={signal} (checkbox) | checked={signal} pass-through + onChange={(e) => signal.set(e.currentTarget.checked)} | Same, for checkboxes |
for:NAME={signal} on <For> | each={signal} + children wrapped in (NAME) => … | Per-item binding declared in one attribute; lifts inner key={…} to getKey automatically |
<For for:item={items}>…</For> (no phaze/getKey) | {() => items().map((item) => …)} (inversion; For import drops when every <For> in the file is inversion-eligible) | Reactive list, SSR-renders every row, zero shipped bytes. The phaze attribute opts into the runtime For (~900 B brotli) when row identity has to survive reorders — see API › <For>. |
JSX children of a component (<Catch><App/></Catch>) | <Catch>{() => <App/>}</Catch> (auto-wrapped) | Flow components (<Catch>, <Switch>, <Portal>, <Dynamic>) work without explicit thunks at every call site |
The full list with per-transform examples lives in DSL & directives. This table is for at-a-glance “what does the compiler actually do.”
What it diagnoses
Section titled “What it diagnoses”phaze-compile also catches compile-time errors and emits DEV-only runtime guards for the common phaze footguns. Both are designed to surface mistakes loudly with the fix in the message — see DSL & directives → Diagnostics for the full catalogue.
Highlights:
use:NAMEwithNAMEnot in scope — build-time error pointing at the missing import or theon:-vs-use:namespace mistake.phaze:onXxx={fn}— build-time error suggestingon:click={fn}(the naïve compile would wire a getter that never fires the handler).bind:value/bind:checkedon incompatible elements — build-time error with the manual-form fallback in the message (covers<input type="number">,<select>,<input type="radio">, etc.).on:click={callExpression}returning a function — DEV-only runtimeconsole.warnwith the bind-to-const fix. Dead-strips in production viaimport.meta.env.DEVgating.
Why every transform is build-time
Section titled “Why every transform is build-time”The runtime cost of every transform on this page is zero. Phaze’s runtime knows nothing about the DSL aliases, the JSX namespaces, or the directive IIFE shape — by the time the runtime sees the code, it’s already plain jsx() calls and plain function-typed JSX prop values that the runtime’s existing fast paths handle. The compiler’s job is to write the boilerplate so you don’t have to; the runtime’s job is unchanged from the no-compiler form.
This is why phaze (the runtime as shipped to the client) stays under 3 KB brotli — every layer of ergonomics is paid for at build time, not at startup.
babel-plugin.ts in phaze-compiler — the engine
Section titled “babel-plugin.ts in phaze-compiler — the engine”A Babel plugin is, at its core, a { visitor: { ... } } object. Each visitor key is an AST node type (CallExpression, JSXElement, ImportDeclaration, …) and each value is a function that fires when Babel’s depth-first traversal encounters that node type. The function can mutate the node, replace it, read scope information, or trigger downstream rewrites by mutating sibling AST.
babel-plugin.ts in phaze-compiler defines visitors that implement every compile-time Phaze transform:
| Visitor | Transforms implemented |
|---|---|
Program.enter | Per-file state reset — dslLocals, numericLocals, timeLocals, matchFreeLocals, matchFactoryLocals, listLocals, needsRuntimeImport. Babel reuses plugin instances across files in batch mode, so a per-file reset is required. |
Program.exit | Drops the /numeric, /match, /list import declarations (per-specifier) when every binding’s references were rewritten. Auto-injects effect / listen imports from @madenowhere/phaze when class: / bind: namespace rewrites referenced them. |
ImportDeclaration | Walks each from '@madenowhere/phaze/...' import and records bindings (c, watch, phaze, s from /dsl; inc, dec, add, sub from /numeric; interval, timeout from /time; is, not, signal/s from /match; remove, push, prepend, replace, patch, matches from /list) in the per-file state maps. The CallExpression visitor reads those maps to decide which calls to rewrite. |
CallExpression | The DSL macros (c(expr)/watch(expr)/phaze(expr)/s.async(expr) auto-thunks), the /numeric inline rewrites (inc(sig) → sig.set(sig() + 1)), the /match rewrites (is(sig, val) → sig() === val, method-form step.is(val) → step() === val), the /list rewrites (remove(sig, { id }) → sig.set(sig().filter(_t => !(_t.id === id))), plus push/prepend/replace/patch/matches), the /time second-arg auto-thunks (interval(delay, expr) → interval(delay, () => expr)). All in one branch-per-shape visitor. |
JSXElement | Four responsibilities: (a) rewrite namespace attributes (phaze:, on:, for:), (b) extract post-creation operations (use:, class:, bind:) and emit the IIFE that runs them, (c) wrap component children in thunks (<Catch><App/></Catch> → <Catch>{() => <App/>}</Catch>), (d) <For> key-lift + inversion — hoists inner key={…} to getKey={(p) => …}, then rewrites <For for:item={items}>…</For> (no phaze/getKey) to {() => items().map((item) => …)} and drops the For import. The phaze opt-in leaves the runtime For shape. |
JSXFragment | Same expression-children-wrap rule as JSX host elements. |
The visitors share per-file state through a PluginPass-shaped object. The Program-enter/exit visitors initialize and finalize that state; the other visitors read and write it.
The plugin is ~800 lines. Most of it is dispatch logic and the JSXElement post-creation-op extractor; the actual rewrites are short. The Babel plugin API has been stable for years and the visitor pattern is well-understood — adding a new transform usually means adding one branch to the right visitor + a test case.
vite-plugin.ts in phaze-compiler — the adapter
Section titled “vite-plugin.ts in phaze-compiler — the adapter”The Vite plugin’s heart is the transform hook (abbreviated below); the full plugin (~475 lines) also handles .phaze pre-processing, the ?lang.tsx tag, variant virtual modules, the @global registry scan, and HMR:
export default function phazeVitePlugin(): VitePlugin { return { name: '@madenowhere/phaze-compile', enforce: 'pre', transform(code: string, id: string) { const path = id.split('?')[0] ?? id if (!/\.(tsx|jsx)$/.test(path)) return null
const out = transformSync(code, { plugins: [phazeCompile], parserOpts: { plugins: ['jsx', 'typescript'] }, filename: path, sourceMaps: true, })
return { code: out?.code ?? '', map: out?.map ?? undefined } }, }}Three things going on:
enforce: 'pre'— runs before Vite’s JSX-to-jsx()transform (Oxc on Vite 8, esbuild on ≤7). By the time the transformer processes the file, all JSX namespace attributes have already been lowered to plain attributes (or to the post-creation IIFE).- File-extension filter — only
.tsx/.jsxfiles get transformed..ts/.js/.astro/.csspass through untouched (thetransformhook returnsnullfor non-matches, signaling “this plugin doesn’t transform this file”). - Babel parser plugins — the parser is configured for both JSX and TypeScript syntax so it can handle
.tsxfiles in one pass.
That’s it. The Vite plugin doesn’t implement any of the transforms; it just decides which files to feed to the Babel plugin and hands the result back to Vite.
.phaze format support — parser + emit + registry + auto-import
Section titled “.phaze format support — parser + emit + registry + auto-import”phaze-compile’s phaze-format subpath (packages/compile/src/phaze-format/) provides the structural .phaze → .tsx pipeline that complements the Babel-level AST rewrites:
| Component | Path | Role |
|---|---|---|
| Parser | parser.ts | State machine TOP / IN_BOUNDARY / BETWEEN / TRAILING. Recognises six named fences (---page / ---data / ---state / ---props / ---platform / ---cloudflare), three device-variant body fences (---mobile / ---tablet / ---desktop), plus the bare --- body-exit. In transport.phaze it also parses the repeating action form — ---<Group>.<action> dotted fences, each a knob region (input: / response: / rust: / use: / sign: / …) closed by a bare --- into the handler body. Tolerates trailing whitespace + // line comment on every fence line. |
| Emit | emit.ts | Walks the ParseResult, produces synthetic .tsx source + a v3 sourcemap. Per-boundary routines: processStateBoundary (Local vs @global split + Q5 enforcement), processPropsBoundary (destructure + type literal synthesis), emitComponentTrailing (routes by propsInfo / explicit-arrow / implicit), emitImplicitArrow. emitActions / emitActionEntry lower each action fence to <action>: newAction({ …knobs, handler }) (auto-injecting the newAction import), including the rust: knob’s generated proxy body (see below). |
| Sourcemap | mapper.ts | Thin wrapper over @jridgewell/gen-mapping with three primitives (push / skip / blank). The Vite plugin chains the emit map through Babel via inputSourceMap so the final source map reaches .phaze positions in one hop. |
The Vite plugin’s transform hook handles both .tsx and .phaze extensions — for .phaze it runs phazeFormatTransform first, then feeds the synthesized .tsx into the Babel pass chain. Build-time errors land at .phaze source positions, not the synthesized intermediate.
Action fences + the rust: knob
Section titled “Action fences + the rust: knob”transport.phaze is the reserved Phaze Transport file: its ---<Group>.<action> fences lower to a newAction config tree. Most knobs (input: / response: / sign: / require: / use:) map onto config fields, and the bare---- body becomes the handler. The rust: knob is the one that generates code rather than passing it through — reaching a Rust endpoint at `${RUST_API}<path>` — and the shape it emits depends on the fence’s other knobs and its body:
- Destructure proxy — a
return { … }field-list body:emitActionEntryemits the full proxy — a cookie-forwardingfetch, aninput:-gatedJSON.stringify(input)body, an!ok→ActionErrorrethrow (import auto-injected), and the JSON destructure. - Passthrough — a
return databody: relay the RustResponseverbatim (so a RustX-Phaze-Sigcovers the exact bytes it signed — never re-wrapped). lang: rusthoist — the body is Rust; its presence forces hoist.build/gen-rust.mjsextracts the body into a real#[get]handler at the URL, and the TS side is always passthrough (never destructure).mint:session-mint —rust:finishes a ceremony and mints the session, relaying the upstreamSet-Cookie(on atransport: ssestream it instead establishes the per-connection DEK).- Direct client→Rust — a fence with no TS-side step (no
use:/require:/mint:/tags:, no classicalencrypt:) is classified byscanFenceDirectand skips the proxy entirely: the compiled client hits the Rust URL directly, the TS worker never in the path. (See Phaze Transport → Internal vs external for the routing + the Rust-side policy attrs a direct fence needs.)
gen: [fn] is the sibling opt-out — recognised, skipped from the config, body left hand-written for composition. See Phaze Transport → Proxying a Rust endpoint.
On Vite 8, the adapter’s resolveId additionally tags every .phaze module id with a ?lang.tsx query — the canonical custom-extension mechanism (the same lang.<ext> convention Vue SFC sub-blocks and @astrojs/vite-plugin-astro use). That makes Oxc recognize the synthesized TSX and perform the JSX-to-jsx() lowering uniformly in dev and build. (On Vite ≤7 the JSX was instead left for esbuild via the host’s esbuild.loader: 'tsx', which Oxc has no per-extension equivalent for — hence the ?lang.tsx tag.) babel-plugin.ts itself never does JSX lowering in either case.
The @global registry — project-wide shared state
Section titled “The @global registry — project-wide shared state”A GlobalRegistry (in packages/compile/src/global-registry.ts, exported as @madenowhere/phaze-compile/global-registry) tracks every @global X : value declaration across the project. The Vite plugin populates it at buildStart via a sync filesystem scan of .phaze files (skips node_modules / dist / .git / .cache / .vite / .phaze / .wrangler), updates it per-transform, and invalidates consumers on HMR when a global’s declaration set changes.
Plugin options control the policy:
phazeVitePlugin({ // (strict mode is the silent default — only `src/app.phaze` may declare // `@global X : value`; any other file gets a Q5 compile error.) // Both options are escape hatches; comment them out for the default behavior. appPhazePath: 'src/shell.phaze', // override the conventional `src/app.phaze` distributedGlobals: true, // allow `@global` declarations in any .phaze file})The babel-plugin’s Program.exit visitor consults the registry to inject auto-imports. For every unresolved ReferencedIdentifier whose name matches a registered global, the visitor prepends import { X } from '<rel>.phaze' at the top of the file. Scope analysis uses ip.scope.hasBinding(name) (the inner-most scope at the reference site), so locals and explicit imports correctly shadow registry entries — standard JS scope rules win.
---props synthesis
Section titled “---props synthesis”processPropsBoundary parses each prop line (<name>[?]: <type> [= <default>]) into a destructured parameter list AND a TypeScript type literal. The synthesized arrow becomes the component’s signature:
---propspost : PostclassName? : string = ''({ post, className = '' }: { post: Post; className?: string }) => …Multi-line types/defaults track brace/paren depth — same machinery as ---state’s value tracking. When ---props is present, the trailing region is forced implicit form (no (params) => tail arrow needed).
Page mode rejects ---props with a diagnostic — pages get inputs via { data } from the loader, not call-site props.
Why Babel (not Oxc, esbuild, swc, or a TypeScript transformer)
Section titled “Why Babel (not Oxc, esbuild, swc, or a TypeScript transformer)”Three reasons, in order:
-
Babel’s plugin API is the standard for arbitrary AST rewriting. Walking the AST via the visitor pattern, swapping nodes, tracking scope, querying bindings via
path.scope.getBinding(name)— all first-class. Vite’s built-in transformer (Oxc on Vite 8, esbuild before it) processes source text → source text; it doesn’t expose an AST to user transforms. swc has a Rust-level plugin API that requires writing transforms in Rust (or using a slow JS bridge), which is a much higher friction surface than Babel’s TypeScript/JS plugins. -
Babel’s parser handles TS + JSX natively. Configure
parserOpts: { plugins: ['jsx', 'typescript'] }and Babel parses.tsxfiles in one pass — no separate TypeScript step needed. The output AST keeps JSX nodes intact, which is critical for phaze-compile because the JSX is what the subsequent transformer pass (Oxc’s JSX-to-jsx()on Vite 8) consumes. -
phaze-compile doesn’t need to be the JSX-to-call transformer. phaze-compile runs at
enforce: 'pre'and emits JSX as output — Vite’s transformer (Oxc on Vite 8) then does the JSX-to-jsx()pass. So the responsibility split is: Babel handles the phaze-specific AST rewrites, Oxc handles the fast JSX-call lowering. Babel doesn’t have to be fast at JSX-call generation (Oxc is much faster at that); Babel just has to be expressive enough for the AST transforms.
The choice of Babel for this layer is purely an implementation detail of phaze-compile. The Phaze runtime contract — what jsx() calls look like, what the JSX runtime expects — is bundler-agnostic. A future phaze-compile-rust written as a swc Rust plugin could replace babel-plugin.ts without any user-visible change.
Where Babel sits in the build pipeline
Section titled “Where Babel sits in the build pipeline”phaze-compile is one stage of a three-stage pipeline that runs over every .tsx file on its way from source to bundle. Babel (phaze-compile), then Vite’s transformer, then Vite’s bundler each have a role; they’re not alternatives but a stack. On Vite 8 (the phaze-cloudflare path) the transformer is Oxc and the bundler is Rolldown — they replaced esbuild and Rollup in Vite 8; phaze-astro still rides Astro’s Vite (esbuild + Rollup until Astro ships Rolldown):
Your .tsx file │ ▼┌───────────────────────────────────────────────────────────────┐│ STAGE 1 — Babel (phaze-compile's babel-plugin.ts) ││ ──────────────────────────────────────────────────── ││ AST rewriting via the visitor API. Implements every ││ phaze-specific transform: ││ • c(expr) → c(() => expr) (DSL auto-thunks) ││ • watch(expr) → effect(() => expr) ││ • s.async(expr) → s.async(() => expr) ││ • inc(count) → count.set(count() + 1) (/numeric inline) ││ • is(step,'a') → step() === 'a' (/match inline) ││ • remove(t,{id}) → t.set(t().filter(_t=>!(_t.id===id))) ││ • matches({id}) → _t => _t.id === id (/list matches) ││ • on:event={…} → onEvent={…} (JSX namespaces) ││ • use:NAME={v} → IIFE post-creation call ││ • <For for:t> → {() => t().map(...)} (inversion, 0 B) ││ • <For for:t phaze> → <For each={…} getKey={…}> ││ • interval(s,n,fn) → interval(() => {s();return n}, fn) ││ ││ OUTPUT: JSX still intact, plus the phaze-specific rewrites. │└───────────────────────────────────────────────────────────────┘ │ ▼┌───────────────────────────────────────────────────────────────┐│ STAGE 2 — Oxc (Vite 8's transformer; esbuild on ≤7) ││ ──────────────────────────────────────────────────── ││ JSX-to-jsx() lowering. Converts every `<Foo bar={1}>` to ││ `jsx(Foo, { bar: 1 })`. Also does TS-strip, minify (in ││ prod), and constant-folds `import.meta.env.DEV`. ││ ││ OUTPUT: plain ES2022 JS, no JSX left. │└───────────────────────────────────────────────────────────────┘ │ ▼┌───────────────────────────────────────────────────────────────┐│ STAGE 3 — Rolldown (Vite 8's bundler; Rollup on ≤7) ││ ──────────────────────────────────────────────────── ││ Tree-shake + chunk + emit final .js files. Reads ││ phazeChunks()'s chunk-grouping decisions, deduplicates ││ modules across the dep graph, drops unused exports. ││ Under phaze-cloudflare (default): the `phaze` runtime + ││ opt-in `phaze-router`; directives / actions / subpaths ││ fold into their component chunks. (Astro splits them ││ out into phaze-directives / phaze-actions when enabled.) ││ ││ OUTPUT: the final bundled .js files (host-specific path). │└───────────────────────────────────────────────────────────────┘Stage 3’s output path depends on the host: dist/_astro/*.js under Astro, or — under phaze-cloudflare’s dual-environment build — dist/client/assets/*.js (browser bundle + manifest) plus a single self-contained dist/server/index.js worker.
Why each tool is in this slot
Section titled “Why each tool is in this slot”| Tool | Strength | Why it’s used here |
|---|---|---|
| Babel | Mature plugin/visitor API. Can traverse the AST, mutate nodes, query scope (path.scope.getBinding), preserve JSX nodes through the transform. | phaze-compile needs all of this for the namespace rewrites + macros + scope-aware /numeric tracking. Oxc (Vite 8’s transformer) and esbuild expose no equivalent public JS visitor API; the Oxc and SWC transformer APIs are Rust-only. |
| Oxc | Rust-based (the oxc toolchain), extremely fast at the JSX-to-call lowering. Vite 8’s built-in transformer — it replaced esbuild in Vite 8. | Vite uses it for the high-volume, schema-stable transforms (JSX lowering, TS-strip, minify). phaze-compile leaves JSX intact so Oxc lowers it downstream — for .phaze files via the ?lang.tsx id tag (above). (On Vite ≤7 / current Astro this stage is esbuild.) |
| Rolldown | Rust-based bundler: best-in-class tree-shaking, the codeSplitting / advancedChunks API for chunk layout, deep cross-module dependency analysis. Vite 8’s bundler — it replaced Rollup in Vite 8. | Production builds need all of this; phazeChunks() feeds the chunk grouping. (On Vite ≤7 / current Astro this stage is Rollup, and the same grouping lives under rollupOptions.output.manualChunks.) |
The split is what makes phaze fast at build time AND aggressive at tree-shake: Babel handles the small set of phaze-specific transforms (slow but expressive AST API), Oxc handles the large volume of generic JSX/TS transforms (fast at the boring stuff), Rolldown handles the final assembly (smartest at deciding what ships where).
Could phaze switch to all-Oxc or all-SWC?
Section titled “Could phaze switch to all-Oxc or all-SWC?”Theoretically yes, but neither pays off:
- All Oxc / esbuild would require the transformer to add a public JS visitor API for arbitrary transforms (neither exposes one to JS — esbuild’s has been requested since 2020).
- All SWC would require rewriting
babel-plugin.tsas a Rust plugin (massive effort) or using SWC’s JS bridge (slower than Babel for the kind of work phaze-compile does).
Today the Babel-as-AST-engine choice has zero practical downside — JSX-to-call lowering (the speed-critical step) still goes through Vite’s transformer (Oxc on Vite 8); Babel only runs on the small set of phaze patterns. The combined pipeline is fast enough that no app I’ve seen complains about build time.
Where size.mjs diverges from the production stack
Section titled “Where size.mjs diverges from the production stack”A maintainer-only detail worth knowing: phaze core’s scripts/size.mjs uses esbuild alone with plain bundling — no Babel pre-pass, no Rollup post-pass, no chunking. That’s why its per-module-attribution numbers are diagnostic approximations, not the real production output. For the canonical real-app bundle sizes — phaze, phaze-directives, etc. — use the sizeReport() plugin from @madenowhere/vite-plugin-phaze in your consumer app’s vite.plugins[]. It measures what comes out of stage 3 (the bundler’s actual chunks — Rolldown on Vite 8) — that’s the honest in-app number.
What a transform looks like in code
Section titled “What a transform looks like in code”Walking through the c(expr) → c(() => expr) auto-thunk as a representative example. The CallExpression visitor sees the call, checks if the callee is a DSL-traced binding, and rewrites the argument:
CallExpression(path, state) { const callee = path.node.callee if (callee.type !== 'Identifier') return
// Look up which DSL primitive this local name refers to. // state.dslLocals was populated by the ImportDeclaration visitor. const dslKind = state.dslLocals?.get(callee.name) if (!dslKind) return // not a DSL call, bail if (dslKind === 's') return // s() is a plain signal alias — no thunk
const arg = path.node.arguments[0] if (!arg) return // Skip if the user already wrote an arrow — idempotent. if ( arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression' || arg.type === 'SpreadElement' ) return
// Wrap the argument in `() => arg`. const arrow = types.arrowFunctionExpression([], arg) path.node.arguments[0] = arrow},Every transform in babel-plugin.ts follows roughly this shape: detect a syntactic pattern via the visitor + state lookup, mutate the AST in-place, return. Idempotence (skipping already-transformed shapes) is enforced via the early-return guards.
Where to go from here
Section titled “Where to go from here”- /phaze-compiler/ — the Reference catalog of every transform, with source/compiled side-by-side.
- DSL & directives — the user-facing documentation for every namespace and DSL primitive the compiler recognizes.
babel-plugin.tsin phaze-compiler (source) — the full plugin, end-to-end.